Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0235bba4e5 | |||
| e1fec87d6f | |||
| 065ef3a495 | |||
| 3188db91da | |||
| acc3b9ae93 | |||
| 183caacff4 | |||
| a3b42ba0ed | |||
| 2e4ff714f6 | |||
| 9e87184fbf | |||
| 09423fb4be | |||
| e4beaf758e | |||
| b6c38b66ee | |||
| d2f38dea07 | |||
| 3bdeefe4e7 | |||
| 179c90ff31 | |||
| e736fa3b2f | |||
| d240a8210e | |||
| 474cb60d82 | |||
| b3a70719a2 | |||
| 83ef1ac174 | |||
| 6b91024aa5 | |||
| 52bec0fa89 | |||
| 76fd130493 | |||
| 6682b44928 | |||
| deb043f928 | |||
| 0677c2e646 | |||
| 28618034b8 | |||
| 6ad14a6d54 | |||
| 1429a81887 | |||
| 15be1dcd4f | |||
| 33b4ff73a4 | |||
| 7a3d8a824e | |||
| 067b1587b9 | |||
| 08876b5843 | |||
| 7e734348da | |||
| 44c589c1a4 | |||
| 54b9be4463 | |||
| bc1e8a6566 | |||
| 7d1028cf11 | |||
| 501bc6e839 | |||
| 4e77d75ea3 | |||
| 60bd40385e | |||
| 2a131c5131 | |||
| 71ce03e548 | |||
| c1b90dc3ac | |||
| 9ad8706223 | |||
| d4063f360e | |||
| 4b28de6d42 | |||
| 491ceaff48 | |||
| d1dfa179b3 | |||
| a806f89a31 | |||
| 0a7bb7f5f4 | |||
| b712cb2695 | |||
| b8dcf6a885 | |||
| 86390a6f12 | |||
| b2a8e0678d | |||
| 3adfdb2341 | |||
| 6c538ca330 | |||
| af8483a25b | |||
| 0281732c5c | |||
| 7bb7b38016 | |||
| d600f55214 | |||
| bbb5035508 | |||
| 31480af845 | |||
| e2676bdc56 | |||
| f55aa875c2 |
@@ -1,551 +0,0 @@
|
||||
components:
|
||||
parameters:
|
||||
bbox:
|
||||
description: Only features that have a geometry that intersects the bounding
|
||||
box are selected.The bounding box is provided as four or six numbers, depending
|
||||
on whether the coordinate reference system includes a vertical axis (height
|
||||
or depth).
|
||||
explode: false
|
||||
in: query
|
||||
name: bbox
|
||||
required: false
|
||||
schema:
|
||||
items:
|
||||
type: number
|
||||
maxItems: 6
|
||||
minItems: 4
|
||||
type: array
|
||||
style: form
|
||||
bbox-crs:
|
||||
description: Indicates the coordinate reference system for the given bbox coordinates.
|
||||
explode: false
|
||||
in: query
|
||||
name: bbox-crs
|
||||
required: false
|
||||
schema:
|
||||
format: uri
|
||||
type: string
|
||||
style: form
|
||||
bbox-crs-epsg:
|
||||
description: Indicates the EPSG for the given bbox coordinates.
|
||||
explode: false
|
||||
in: query
|
||||
name: bbox-crs
|
||||
required: false
|
||||
schema:
|
||||
default: 4326
|
||||
type: integer
|
||||
style: form
|
||||
crs:
|
||||
description: Indicates the coordinate reference system for the results.
|
||||
explode: false
|
||||
in: query
|
||||
name: crs
|
||||
required: false
|
||||
schema:
|
||||
format: uri
|
||||
type: string
|
||||
style: form
|
||||
f:
|
||||
description: The optional f parameter indicates the output format which the
|
||||
server shall provide as part of the response document. The default format
|
||||
is GeoJSON.
|
||||
explode: false
|
||||
in: query
|
||||
name: f
|
||||
required: false
|
||||
schema:
|
||||
default: json
|
||||
enum:
|
||||
- json
|
||||
- html
|
||||
- jsonld
|
||||
type: string
|
||||
style: form
|
||||
lang:
|
||||
description: The optional lang parameter instructs the server return a response
|
||||
in a certain language, if supported. If the language is not among the available
|
||||
values, the Accept-Language header language will be used if it is supported.
|
||||
If the header is missing, the default server language is used. Note that providers
|
||||
may only support a single language (or often no language at all), that can
|
||||
be different from the server language. Language strings can be written in
|
||||
a complex (e.g. "fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7"), simple (e.g. "de")
|
||||
or locale-like (e.g. "de-CH" or "fr_BE") fashion.
|
||||
in: query
|
||||
name: lang
|
||||
required: false
|
||||
schema:
|
||||
default: en-US
|
||||
enum:
|
||||
- en-US
|
||||
- fr-CA
|
||||
type: string
|
||||
offset:
|
||||
description: The optional offset parameter indicates the index within the result
|
||||
set from which the server shall begin presenting results in the response document. The
|
||||
first element has an index of 0 (default).
|
||||
explode: false
|
||||
in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema:
|
||||
default: 0
|
||||
minimum: 0
|
||||
type: integer
|
||||
style: form
|
||||
resourceId:
|
||||
description: Configuration resource identifier
|
||||
in: path
|
||||
name: resourceId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
skipGeometry:
|
||||
description: This option can be used to skip response geometries for each feature.
|
||||
explode: false
|
||||
in: query
|
||||
name: skipGeometry
|
||||
required: false
|
||||
schema:
|
||||
default: false
|
||||
type: boolean
|
||||
style: form
|
||||
vendorSpecificParameters:
|
||||
description: Additional "free-form" parameters that are not explicitly defined
|
||||
in: query
|
||||
name: vendorSpecificParameters
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
style: form
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
'204':
|
||||
description: no content
|
||||
Queryables:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/queryables'
|
||||
description: successful queryables operation
|
||||
Tiles:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/tiles'
|
||||
description: Retrieves the tiles description for this collection
|
||||
default:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/schemas/exception.yaml
|
||||
description: Unexpected error
|
||||
schemas:
|
||||
queryable:
|
||||
properties:
|
||||
description:
|
||||
description: a human-readable narrative describing the queryable
|
||||
type: string
|
||||
language:
|
||||
default:
|
||||
- en
|
||||
description: the language used for the title and description
|
||||
type: string
|
||||
queryable:
|
||||
description: the token that may be used in a CQL predicate
|
||||
type: string
|
||||
title:
|
||||
description: a human readable title for the queryable
|
||||
type: string
|
||||
type:
|
||||
description: the data type of the queryable
|
||||
type: string
|
||||
type-ref:
|
||||
description: a reference to the formal definition of the type
|
||||
format: url
|
||||
type: string
|
||||
required:
|
||||
- queryable
|
||||
- type
|
||||
type: object
|
||||
queryables:
|
||||
properties:
|
||||
queryables:
|
||||
items:
|
||||
$ref: '#/components/schemas/queryable'
|
||||
type: array
|
||||
required:
|
||||
- queryables
|
||||
type: object
|
||||
tilematrixsetlink:
|
||||
properties:
|
||||
tileMatrixSet:
|
||||
type: string
|
||||
tileMatrixSetURI:
|
||||
type: string
|
||||
required:
|
||||
- tileMatrixSet
|
||||
type: object
|
||||
tiles:
|
||||
properties:
|
||||
links:
|
||||
items:
|
||||
$ref: https://schemas.opengis.net/ogcapi/tiles/part1/1.0/openapi/ogcapi-tiles-1.yaml#/components/schemas/link
|
||||
type: array
|
||||
tileMatrixSetLinks:
|
||||
items:
|
||||
$ref: '#/components/schemas/tilematrixsetlink'
|
||||
type: array
|
||||
required:
|
||||
- tileMatrixSetLinks
|
||||
- links
|
||||
type: object
|
||||
info:
|
||||
contact:
|
||||
email: you@example.org
|
||||
name: Organization Name
|
||||
url: https://pygeoapi.io
|
||||
description: pygeoapi provides an API to geospatial data
|
||||
license:
|
||||
name: CC-BY 4.0 license
|
||||
url: https://creativecommons.org/licenses/by/4.0/
|
||||
termsOfService: https://creativecommons.org/licenses/by/4.0/
|
||||
title: Speckle pygeoapi instance
|
||||
version: 0.18.dev0
|
||||
x-keywords:
|
||||
- geospatial
|
||||
- data
|
||||
- api
|
||||
openapi: 3.0.2
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
description: Landing page
|
||||
operationId: getLandingPage
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/f'
|
||||
- $ref: '#/components/parameters/lang'
|
||||
responses:
|
||||
'200':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/LandingPage
|
||||
'400':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
|
||||
'500':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
|
||||
summary: Landing page
|
||||
tags:
|
||||
- server
|
||||
/collections:
|
||||
get:
|
||||
description: Collections
|
||||
operationId: getCollections
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/f'
|
||||
- $ref: '#/components/parameters/lang'
|
||||
responses:
|
||||
'200':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/LandingPage
|
||||
'400':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
|
||||
'500':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
|
||||
summary: Collections
|
||||
tags:
|
||||
- server
|
||||
/collections/speckle:
|
||||
get:
|
||||
description: Latest version of Speckle Model data
|
||||
operationId: describeSpeckleCollection
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/f'
|
||||
- $ref: '#/components/parameters/lang'
|
||||
responses:
|
||||
'200':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Collection
|
||||
'400':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
|
||||
'404':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound
|
||||
'500':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
|
||||
summary: Get Speckle data metadata
|
||||
tags:
|
||||
- speckle
|
||||
/collections/speckle/items:
|
||||
get:
|
||||
description: Latest version of Speckle Model data
|
||||
operationId: getSpeckleFeatures
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/f'
|
||||
- $ref: '#/components/parameters/lang'
|
||||
- $ref: '#/components/parameters/bbox'
|
||||
- $ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/limit
|
||||
- $ref: '#/components/parameters/crs'
|
||||
- $ref: '#/components/parameters/bbox-crs'
|
||||
- description: The properties that should be included for each feature. The
|
||||
parameter value is a comma-separated list of property names.
|
||||
explode: false
|
||||
in: query
|
||||
name: properties
|
||||
required: false
|
||||
schema:
|
||||
items:
|
||||
enum: []
|
||||
type: string
|
||||
type: array
|
||||
style: form
|
||||
- $ref: '#/components/parameters/vendorSpecificParameters'
|
||||
- $ref: '#/components/parameters/skipGeometry'
|
||||
- $ref: https://raw.githubusercontent.com/opengeospatial/ogcapi-records/master/core/openapi/parameters/sortby.yaml
|
||||
- $ref: '#/components/parameters/offset'
|
||||
responses:
|
||||
'200':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Features
|
||||
'400':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
|
||||
'404':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound
|
||||
'500':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
|
||||
summary: Get Speckle data items
|
||||
tags:
|
||||
- speckle
|
||||
options:
|
||||
description: Latest version of Speckle Model data
|
||||
operationId: optionsSpeckleFeatures
|
||||
responses:
|
||||
'200':
|
||||
description: options response
|
||||
summary: Options for Speckle data items
|
||||
tags:
|
||||
- speckle
|
||||
/collections/speckle/items/{featureId}:
|
||||
get:
|
||||
description: Latest version of Speckle Model data
|
||||
operationId: getSpeckleFeature
|
||||
parameters:
|
||||
- $ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/featureId
|
||||
- $ref: '#/components/parameters/crs'
|
||||
- $ref: '#/components/parameters/f'
|
||||
- $ref: '#/components/parameters/lang'
|
||||
responses:
|
||||
'200':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Feature
|
||||
'400':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
|
||||
'404':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound
|
||||
'500':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
|
||||
summary: Get Speckle data item by id
|
||||
tags:
|
||||
- speckle
|
||||
options:
|
||||
description: Latest version of Speckle Model data
|
||||
operationId: optionsSpeckleFeature
|
||||
parameters:
|
||||
- $ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/featureId
|
||||
responses:
|
||||
'200':
|
||||
description: options response
|
||||
summary: Options for Speckle data item by id
|
||||
tags:
|
||||
- speckle
|
||||
/conformance:
|
||||
get:
|
||||
description: API conformance definition
|
||||
operationId: getConformanceDeclaration
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/f'
|
||||
- $ref: '#/components/parameters/lang'
|
||||
responses:
|
||||
'200':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/LandingPage
|
||||
'400':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
|
||||
'500':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
|
||||
summary: API conformance definition
|
||||
tags:
|
||||
- server
|
||||
/jobs:
|
||||
get:
|
||||
description: Retrieve a list of jobs
|
||||
operationId: getJobs
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/200'
|
||||
'404':
|
||||
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml
|
||||
default:
|
||||
$ref: '#/components/responses/default'
|
||||
summary: Retrieve jobs list
|
||||
tags:
|
||||
- jobs
|
||||
/jobs/{jobId}:
|
||||
delete:
|
||||
description: Cancel / delete job
|
||||
operationId: deleteJob
|
||||
parameters:
|
||||
- &id001
|
||||
description: job identifier
|
||||
in: path
|
||||
name: jobId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
$ref: '#/components/responses/204'
|
||||
'404':
|
||||
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml
|
||||
default:
|
||||
$ref: '#/components/responses/default'
|
||||
summary: Cancel / delete job
|
||||
tags:
|
||||
- jobs
|
||||
get:
|
||||
description: Retrieve job details
|
||||
operationId: getJob
|
||||
parameters:
|
||||
- *id001
|
||||
- $ref: '#/components/parameters/f'
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/200'
|
||||
'404':
|
||||
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml
|
||||
default:
|
||||
$ref: '#/components/responses/default'
|
||||
summary: Retrieve job details
|
||||
tags:
|
||||
- jobs
|
||||
/jobs/{jobId}/results:
|
||||
get:
|
||||
description: Retrieve job results
|
||||
operationId: getJobResults
|
||||
parameters:
|
||||
- *id001
|
||||
- $ref: '#/components/parameters/f'
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/200'
|
||||
'404':
|
||||
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml
|
||||
default:
|
||||
$ref: '#/components/responses/default'
|
||||
summary: Retrieve job results
|
||||
tags:
|
||||
- jobs
|
||||
/openapi:
|
||||
get:
|
||||
description: This document
|
||||
operationId: getOpenapi
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/f'
|
||||
- $ref: '#/components/parameters/lang'
|
||||
- description: UI to render the OpenAPI document
|
||||
explode: false
|
||||
in: query
|
||||
name: ui
|
||||
required: false
|
||||
schema:
|
||||
default: swagger
|
||||
enum:
|
||||
- swagger
|
||||
- redoc
|
||||
type: string
|
||||
style: form
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/200'
|
||||
'400':
|
||||
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
|
||||
default:
|
||||
$ref: '#/components/responses/default'
|
||||
summary: This document
|
||||
tags:
|
||||
- server
|
||||
/processes:
|
||||
get:
|
||||
description: Processes
|
||||
operationId: getProcesses
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/f'
|
||||
responses:
|
||||
'200':
|
||||
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/ProcessList.yaml
|
||||
default:
|
||||
$ref: '#/components/responses/default'
|
||||
summary: Processes
|
||||
tags:
|
||||
- server
|
||||
/processes/hello-world:
|
||||
get:
|
||||
description: An example process that takes a name as input, and echoes it back
|
||||
as output. Intended to demonstrate a simple process with a single literal
|
||||
input.
|
||||
operationId: describeHello-worldProcess
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/f'
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/200'
|
||||
default:
|
||||
$ref: '#/components/responses/default'
|
||||
summary: Get process metadata
|
||||
tags:
|
||||
- hello-world
|
||||
/processes/hello-world/execution:
|
||||
post:
|
||||
description: An example process that takes a name as input, and echoes it back
|
||||
as output. Intended to demonstrate a simple process with a single literal
|
||||
input.
|
||||
operationId: executeHello-worldJob
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
example:
|
||||
inputs:
|
||||
message: An optional message.
|
||||
name: World
|
||||
schema:
|
||||
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/schemas/execute.yaml
|
||||
description: Mandatory execute request JSON
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/200'
|
||||
'201':
|
||||
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/ExecuteAsync.yaml
|
||||
'404':
|
||||
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml
|
||||
'500':
|
||||
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/ServerError.yaml
|
||||
default:
|
||||
$ref: '#/components/responses/default'
|
||||
summary: Process Hello World execution
|
||||
tags:
|
||||
- hello-world
|
||||
servers:
|
||||
- description: pygeoapi provides an API to geospatial data
|
||||
url: https://geo.speckle.systems
|
||||
tags:
|
||||
- description: pygeoapi provides an API to geospatial data
|
||||
externalDocs:
|
||||
description: information
|
||||
url: https://example.org
|
||||
name: server
|
||||
- description: Latest version of Speckle Model data
|
||||
name: speckle
|
||||
- name: coverages
|
||||
- name: edr
|
||||
- name: records
|
||||
- name: features
|
||||
- name: maps
|
||||
- name: processes
|
||||
- name: jobs
|
||||
- name: tiles
|
||||
- name: stac
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
custom: ['https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=WT27AS28UFSNW&source=url'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
custom: ['https://github.com/geopython/pygeoapi/wiki/Sponsorship'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
@@ -62,6 +62,12 @@ jobs:
|
||||
host node port: 9300
|
||||
node port: 9300
|
||||
discovery type: 'single-node'
|
||||
- name: Install and run OpenSearch 📦
|
||||
uses: esmarkowski/opensearch-github-action@v1.0.0
|
||||
with:
|
||||
version: 2.12.0
|
||||
security-disabled: true
|
||||
port: 9209
|
||||
- name: Install and run MongoDB
|
||||
uses: supercharge/mongodb-github-action@1.5.0
|
||||
with:
|
||||
@@ -94,13 +100,12 @@ jobs:
|
||||
pip3 install -r requirements-manager.txt
|
||||
pip3 install -r requirements-django.txt
|
||||
python3 setup.py install
|
||||
pip3 install --upgrade numpy elasticsearch
|
||||
pip3 install --upgrade numpy "sqlalchemy<2"
|
||||
pip3 install --global-option=build_ext --global-option="-I/usr/include/gdal" GDAL==`gdal-config --version`
|
||||
#pip3 install --upgrade rasterio==1.1.8
|
||||
- name: setup test data ⚙️
|
||||
run: |
|
||||
python3 tests/load_es_data.py tests/data/ne_110m_populated_places_simple.geojson geonameid
|
||||
python3 tests/load_opensearch_data.py tests/data/ne_110m_populated_places_simple.geojson geonameid
|
||||
python3 tests/load_mongo_data.py tests/data/ne_110m_populated_places_simple.geojson
|
||||
gunzip < tests/data/hotosm_bdi_waterways.sql.gz | psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test
|
||||
psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test -f tests/data/dummy_data.sql
|
||||
@@ -119,6 +124,7 @@ jobs:
|
||||
pytest tests/test_csv__provider.py
|
||||
pytest tests/test_django.py
|
||||
pytest tests/test_elasticsearch__provider.py
|
||||
pytest tests/test_opensearch__provider.py
|
||||
pytest tests/test_esri_provider.py
|
||||
pytest tests/test_filesystem_provider.py
|
||||
pytest tests/test_geojson_provider.py
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
working-directory: .
|
||||
steps:
|
||||
- name: Checkout pygeoapi
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@master
|
||||
- name: Scan vulnerabilities with trivy
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
@@ -37,6 +37,9 @@ jobs:
|
||||
docker buildx build -t ${{ github.repository }}:${{ github.sha }} --platform linux/amd64 --no-cache -f Dockerfile .
|
||||
- name: Scan locally built Docker image for vulnerabilities with trivy
|
||||
uses: aquasecurity/trivy-action@master
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db:2
|
||||
TRIVY_JAVA_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-java-db:1
|
||||
with:
|
||||
scan-type: image
|
||||
exit-code: 1
|
||||
|
||||
+1
-3
@@ -1,6 +1,3 @@
|
||||
access_log
|
||||
error_log*
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -107,6 +104,7 @@ ENV/
|
||||
*.openapi.yml
|
||||
|
||||
# development setup examples
|
||||
example-config.yml
|
||||
example-openapi.yml
|
||||
|
||||
# misc
|
||||
|
||||
+1
-2
@@ -34,7 +34,7 @@
|
||||
#
|
||||
# =================================================================
|
||||
|
||||
FROM ubuntu:jammy-20240627.1
|
||||
FROM ubuntu:jammy-20240911.1
|
||||
|
||||
LABEL maintainer="Just van den Broecke <justb4@gmail.com>"
|
||||
|
||||
@@ -98,7 +98,6 @@ ENV TZ=${TZ} \
|
||||
python3-greenlet \
|
||||
python3-pip \
|
||||
python3-tz \
|
||||
python3-unicodecsv \
|
||||
python3-yaml \
|
||||
${ADD_DEB_PACKAGES}"
|
||||
|
||||
|
||||
@@ -8,174 +8,3 @@
|
||||
[pygeoapi](https://pygeoapi.io) is a Python server implementation of the [OGC API](https://ogcapi.ogc.org) suite of standards. The project emerged as part of the next generation OGC API efforts in 2018 and provides the capability for organizations to deploy a RESTful OGC API endpoint using OpenAPI, GeoJSON, and HTML. pygeoapi is [open source](https://opensource.org/) and released under an [MIT license](https://github.com/geopython/pygeoapi/blob/master/LICENSE.md).
|
||||
|
||||
Please read the docs at [https://docs.pygeoapi.io](https://docs.pygeoapi.io) for more information.
|
||||
|
||||
# Speckle implementation of pygeoapi
|
||||
|
||||
## How to use Speckle data through OGC API Features
|
||||
|
||||
This is the test deployment of the OGC API server for public Speckle projects. It allows you to share your Speckle model as geospatial data in the format of OGC API Features / Web Feature Service, so it can be natively added to a QGIS, ArcGIS or Civil3D project, or embedded into a web map using Leaflet, OpenLayers or other libraries.
|
||||
|
||||
Demo page: https://geo.speckle.systems/
|
||||
|
||||
### How to construct a valid URL to get georeferenced Speckle layer
|
||||
URL should start with 'https://geo.speckle.systems/?' followed by required and optional parameters. Parameters should be separated with '&' symbol. You can use the generated link to access OGC API dataset in your preferred software, as well as explore the data in the browser and share with others.
|
||||
|
||||
Use the following URL parameters to construct a link that provides Speckle data with your preferred settings::
|
||||
- speckleUrl (text), required, should contain path to a specific Model in Speckle Project, e.g. 'https://app.speckle.systems/projects/55a29f3e9d/models/2d497a381d'
|
||||
- dataType (text), optional, choose from: points, lines, polygons or projectcomments
|
||||
- limit (positive integer), recommended, as some applications might apply their custom feature limit
|
||||
- preserveAttributes (string), optional, choose from: true, false. If not set, meshes will be split into separate polygons for better display quality.
|
||||
- crsAuthid (text), an authority string e.g. 'epsg:4326'. If set, LAT, LON and NORTHDEGREES arguments will be ignored.
|
||||
- lat (number), in range -90 to 90
|
||||
- lon (number), in range -180 to 180
|
||||
- northDegrees (number), in range -180 to 180
|
||||
If GIS-originated Speckle model is loaded, no location arguments are needed.
|
||||
|
||||
Example: [https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828](https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828)
|
||||
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
List of possible issues you can experience and solutions to them:
|
||||
|
||||
- Page or Map stays blank and Developer Tools Console shows "net::ERR_QUIC_PROTOCOL_ERROR 200 (OK)"
|
||||
|
||||
Solution: Try reloading the page. Otherwise, if in Google Chrome, navigate to chrome://flags/#enable-quic and change Experimental QUIC Protocol dropdown to Disabled.
|
||||
|
||||
- Model seems to be loaded incomplete
|
||||
|
||||
Solution: Check the message "feature count limited to ..." next to the Model name on the top of the page. If the message is present, try increasing the feature limit using "&limit=10000" URL parameter
|
||||
|
||||
- Attribute table doesn't have original feature attributes and properties
|
||||
|
||||
Solution: Enable the URL parameter "&preserveAttributes=true". It is disabled by default due to the faulty display of the 3-dimentional multiPolygons overlapping themselves in 2d space, when viweving in the browser on 2d map. Enabling this parameter might make the multipolygons appear "transparent" due to self-overlap.
|
||||
|
||||
Report any other issues here or on our [Community Forum](https://speckle.community/).
|
||||
|
||||
## Add Speckle Feature Layers to web-based maps and desktop apps
|
||||
|
||||
### Add Speckle layer in Javascript
|
||||
|
||||
Javascript-based mapping libraries can load speckle data as JSON using the following function:
|
||||
|
||||
```javascript
|
||||
async function loadSpeckleData() => {
|
||||
var speckle_model_url = 'https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=polygons';
|
||||
const speckle_data = await fetch(speckle_model_url, {
|
||||
headers: {'Accept': 'application/geo+json'}
|
||||
}).then(response => response.json());
|
||||
}
|
||||
```
|
||||
|
||||
Then you can add it to the base map (e.g. using Leaflet and OpenStreetMap basemap tiles). The following example assumes an html div element with id="items-map":
|
||||
|
||||
```html
|
||||
<script>
|
||||
var map = L.map('items-map').setView([ 45 , -75 ], 5);
|
||||
map.addLayer(new L.TileLayer(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 22,
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a> © Data: <a href="https://speckle.systems/">Speckle Systems</a>'
|
||||
}
|
||||
));
|
||||
loadSpeckleData();
|
||||
|
||||
async function loadSpeckleData() => {
|
||||
var speckle_model_url = 'https://geo.speckle.systems/speckle/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=polygons';
|
||||
const speckle_data = await fetch(speckle_model_url, {
|
||||
headers: {'Accept': 'application/geo+json'}
|
||||
}).then(response => response.json());
|
||||
|
||||
speckle_layer = L.geoJSON(speckle_data, {
|
||||
onEachFeature: function (feature, layer) {
|
||||
layer.setStyle({
|
||||
fillColor: feature.displayProperties['color'],
|
||||
color: myFillColor,
|
||||
fillOpacity: 0.8,
|
||||
weight: feature.displayProperties['lineWidth'],
|
||||
radius: feature.displayProperties['radius']
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
speckle_layer.addTo(map);
|
||||
map.fitBounds(speckle_layer.getBounds())
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
Check out 'speckle_demos' folder for more Leaflet and OpenLayers implementation.
|
||||
|
||||
### Add Speckle WFS layer in QGIS
|
||||
1. Add new WFS Layer
|
||||
|
||||

|
||||
|
||||
2. Create New connection, specify the name and URL with mandatory "speckleUrl" parameter pointing to the Speckle Model. Preferably add the URL parameter with the custom feature limit (e.g. '&limit=10000'). Then click Detect, and the WFS Version should display "OGC API Features". Click OK.
|
||||
|
||||

|
||||
|
||||
3. Connect, select the dataset "Speckle data" and click "Add".
|
||||
|
||||

|
||||
|
||||
4. Loading of the data might take a minute, then you will be able to Zoom to layer and check the Attribute table. Done!
|
||||
|
||||

|
||||
|
||||
|
||||
### Add Speckle OGC API layer in ArcGIS
|
||||
|
||||
1. Add new OGC API Connection
|
||||
|
||||

|
||||
|
||||
2. Add URL, preferably add the URL parameter with the custom feature limit (e.g. '&limit=10000')
|
||||
|
||||

|
||||
|
||||
3. Find Speckle Pygeoapi server in Catalog, add SpeckleData layer to Map
|
||||
|
||||

|
||||
|
||||
|
||||
### Add Speckle WFS layer in Civil3D
|
||||
|
||||
TODO
|
||||
|
||||
## Local dev
|
||||
First launch:
|
||||
```python
|
||||
python -m venv pygeoapi_venv
|
||||
cd pygeoapi_venv
|
||||
Scripts\activate
|
||||
cd pygeoapi
|
||||
git clone https://github.com/specklesystems/pygeoapi
|
||||
git checkout dev
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
python -m pip install --upgrade specklepy==2.19.6
|
||||
python -m pip install pydantic==1.10.17
|
||||
python pygeoapi\provider\speckle_utils\patch\patch_specklepy.py
|
||||
python setup.py install
|
||||
set PYGEOAPI_CONFIG=example-config.yml // export
|
||||
set PYGEOAPI_OPENAPI=example-config.yml // export
|
||||
set MAPTILER_KEY_LOCAL=your_api_key // export, (if available)
|
||||
pygeoapi openapi generate $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI
|
||||
pygeoapi serve
|
||||
```
|
||||
|
||||
Repeated launch:
|
||||
```python
|
||||
cd pygeoapi_venv
|
||||
Scripts\activate
|
||||
cd pygeoapi
|
||||
python setup.py install
|
||||
set PYGEOAPI_CONFIG=example-config.yml
|
||||
set PYGEOAPI_OPENAPI=example-config.yml
|
||||
set MAPTILER_KEY_LOCAL=your_api_key
|
||||
pygeoapi openapi generate $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI
|
||||
pygeoapi serve
|
||||
|
||||
```
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ server:
|
||||
limit: 10
|
||||
# templates: /path/to/templates
|
||||
map:
|
||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
ogc_schemas_location: /schemas.opengis.net
|
||||
|
||||
logging:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
@@ -16,11 +16,14 @@
|
||||
<a title="OGC Reference Implementation" href="https://www.ogc.org/resource/products/details/?pid=1663">
|
||||
<img alt="OGC Reference Implementation" src="https://portal.ogc.org/public_ogc/compliance/badge.php?s=ogcapi-tiles-1%201.0.1&r=1&n=1)" width="164" height="44"/>
|
||||
</a>
|
||||
<a title="OGC Reference Implementation" href="https://www.ogc.org/resource/products/details/?pid=1826">
|
||||
<img alt="OGC Reference Implementation" src="https://portal.ogc.org/public_ogc/compliance/badge.php?s=ogcapi-processes-1%201.0.1&n=1)" width="164" height="44"/>
|
||||
</a>
|
||||
<a title="OSGeo Project" href="https://www.osgeo.org/projects/pygeoapi">
|
||||
<img style="background: white;" alt="OSGeo Project" src="https://raw.githubusercontent.com/OSGeo/osgeo/master/incubation/project/OSGeo_project.png" width="164" height="64"/>
|
||||
</a>
|
||||
<a title="FOSS4G Conference" href="https://2023.foss4g.org">
|
||||
<img style="background: white;" alt="FOSS4G Conference" width="145" height="45" src="https://2023.foss4g.org/img/logo.png"/>
|
||||
<a title="FOSS4G Conference" href="https://2024.foss4g.org">
|
||||
<img style="background: white;" alt="FOSS4G Conference" width="145" height="45" src="https://2024.foss4g.org/_next/static/media/foss4g-belem-logo-vertical.30d8343b.svg"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -14,6 +14,15 @@ The API is enabled with the following server configuration:
|
||||
server:
|
||||
admin: true # boolean on whether to enable Admin API.
|
||||
|
||||
.. note::
|
||||
|
||||
If you generate the OpenAPI definition after enabling the admin API, the admin routes will be exposed on ``/openapi``
|
||||
|
||||
.. image:: /_static/openapi_admin.png
|
||||
:alt: admin routes
|
||||
:align: center
|
||||
|
||||
|
||||
Access control
|
||||
--------------
|
||||
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ today_fmt = '%Y-%m-%d'
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.18.dev0'
|
||||
version = '0.19.dev0'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = version
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ For more information related to API design rules (the ``api_rules`` property in
|
||||
static: /path/to/static/folder # path to static folder containing css, js, images and other static files referenced by the template
|
||||
|
||||
map: # leaflet map setup for HTML pages
|
||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
ogc_schemas_location: /opt/schemas.opengis.net # local copy of https://schemas.opengis.net
|
||||
|
||||
manager: # optional OGC API - Processes asynchronous job management
|
||||
@@ -241,7 +241,7 @@ default.
|
||||
option_name: option_value
|
||||
|
||||
hello-world: # name of process
|
||||
type: collection # REQUIRED (collection, process, or stac-collection)
|
||||
type: process # REQUIRED (collection, process, or stac-collection)
|
||||
processor:
|
||||
name: HelloWorld # Python path of process definition
|
||||
|
||||
|
||||
@@ -72,9 +72,12 @@ The `Xarray`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data.
|
||||
data: tests/data/coads_sst.nc
|
||||
# optionally specify x/y/time fields, else provider will attempt
|
||||
# to derive automagically
|
||||
x_field: lat
|
||||
x_field: lon
|
||||
y_field: lat
|
||||
time_field: time
|
||||
# optionally specify the coordinate reference system of your dataset
|
||||
# else pygeoapi assumes it is WGS84 (EPSG:4326).
|
||||
storage_crs: 4326
|
||||
format:
|
||||
name: netcdf
|
||||
mimetype: application/x-netcdf
|
||||
@@ -96,6 +99,11 @@ The `Xarray`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data.
|
||||
be sure to provide the full S3 URL. Any parameters required to open the dataset
|
||||
using fsspec can be added to the config file under `options` and `s3`.
|
||||
|
||||
.. note::
|
||||
When providing a `storage_crs` value in the xarray configuration, specify the
|
||||
coordinate reference system using any valid input for
|
||||
`pyproj.CRS.from_user_input`_.
|
||||
|
||||
Data access examples
|
||||
--------------------
|
||||
|
||||
@@ -146,3 +154,4 @@ Data access examples
|
||||
.. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF
|
||||
.. _`Zarr`: https://zarr.readthedocs.io/en/stable
|
||||
.. _`GDAL raster driver short name`: https://gdal.org/drivers/raster/index.html
|
||||
.. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input
|
||||
|
||||
@@ -44,9 +44,12 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data
|
||||
data: tests/data/coads_sst.nc
|
||||
# optionally specify x/y/time fields, else provider will attempt
|
||||
# to derive automagically
|
||||
x_field: lat
|
||||
x_field: lon
|
||||
y_field: lat
|
||||
time_field: time
|
||||
# optionally specify the coordinate reference system of your dataset
|
||||
# else pygeoapi assumes it is WGS84 (EPSG:4326).
|
||||
storage_crs: 4326
|
||||
format:
|
||||
name: netcdf
|
||||
mimetype: application/x-netcdf
|
||||
@@ -81,6 +84,11 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data
|
||||
S3 URL. Any parameters required to open the dataset using fsspec can be added
|
||||
to the config file under `options` and `s3`, as shown above.
|
||||
|
||||
.. note::
|
||||
When providing a `storage_crs` value in the EDR configuration, specify the
|
||||
coordinate reference system using any valid input for
|
||||
`pyproj.CRS.from_user_input`_.
|
||||
|
||||
|
||||
Data access examples
|
||||
--------------------
|
||||
@@ -105,6 +113,7 @@ Data access examples
|
||||
.. _`xarray`: https://docs.xarray.dev/en/stable/
|
||||
.. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF
|
||||
.. _`Zarr`: https://zarr.readthedocs.io/en/stable
|
||||
.. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input
|
||||
|
||||
|
||||
.. _`OGC Environmental Data Retrieval (EDR) (API)`: https://github.com/opengeospatial/ogcapi-coverages
|
||||
|
||||
@@ -26,7 +26,9 @@ parameters.
|
||||
`GeoJSON`_,✅/✅,results/hits,❌,❌,❌,✅,❌,❌,✅
|
||||
`MongoDB`_,✅/❌,results,✅,✅,✅,✅,❌,❌,✅
|
||||
`OGR`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
|
||||
`OpenSearch`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅,✅
|
||||
`Oracle`_,✅/✅,results/hits,✅,❌,✅,✅,❌,❌,✅
|
||||
`Parquet`_,✅/✅,results/hits,✅,✅,❌,✅,❌,❌,✅
|
||||
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,❌,✅
|
||||
`SQLiteGPKG`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
|
||||
`SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅
|
||||
@@ -144,7 +146,11 @@ To publish an ESRI `Feature Service`_ or `Map Service`_ specify the URL for the
|
||||
|
||||
* ``id_field`` will often be ``OBJECTID``, ``objectid``, or ``FID``.
|
||||
* If the map or feature service is not shared publicly, the ``username`` and ``password`` fields can be set in the
|
||||
configuration to authenticate into the service.
|
||||
configuration to authenticate to the service.
|
||||
* If the map or feature service is self-hosted and not shared publicly, the ``token_service`` and optional ``referer`` fields
|
||||
can be set in the configuration to authenticate to the service.
|
||||
|
||||
To publish from an ArcGIS online hosted service:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
@@ -157,6 +163,24 @@ To publish an ESRI `Feature Service`_ or `Map Service`_ specify the URL for the
|
||||
crs: 4326 # Optional crs (default is EPSG:4326)
|
||||
username: username # Optional ArcGIS username
|
||||
password: password # Optional ArcGIS password
|
||||
token_service: https://your.server.com/arcgis/sharing/rest/generateToken # optional URL to your generateToken service
|
||||
referer: https://your.server.com # optional referer, defaults to https://www.arcgis.com if not set
|
||||
|
||||
To publish from a self-hosted service that is not publicly accessible, the ``token_service`` field is required:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
providers:
|
||||
- type: feature
|
||||
name: ESRI
|
||||
data: https://your.server.com/arcgis/rest/services/your-layer/MapServer/0
|
||||
id_field: objectid
|
||||
time_field: date_in_your_device_time_zone # Optional time field
|
||||
crs: 4326 # Optional crs (default is EPSG:4326)
|
||||
username: username # Optional ArcGIS username
|
||||
password: password # Optional ArcGIS password
|
||||
token_service: https://your.server.com/arcgis/sharing/rest/generateToken # Optional url to your generateToken service
|
||||
referer: https://your.server.com # Optional referer, defaults to https://www.arcgis.com if not set
|
||||
|
||||
GeoJSON
|
||||
^^^^^^^
|
||||
@@ -299,6 +323,44 @@ The OGR provider requires a recent (3+) version of GDAL to be installed.
|
||||
The `crs` query parameter is used as follows:
|
||||
e.g. ``http://localhost:5000/collections/foo/items?crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992``.
|
||||
|
||||
.. _OpenSearch:
|
||||
|
||||
OpenSearch
|
||||
^^^^^^^^^^
|
||||
|
||||
.. note::
|
||||
Requires Python package opensearch-py
|
||||
|
||||
To publish an OpenSearch index, the following are required in your index:
|
||||
|
||||
* indexes must be documents of valid GeoJSON Features
|
||||
* index mappings must define the GeoJSON ``geometry`` as a ``geo_shape``
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
providers:
|
||||
- type: feature
|
||||
name: OpenSearch
|
||||
editable: true|false # optional, default is false
|
||||
data: http://localhost:9200/ne_110m_populated_places_simple
|
||||
id_field: geonameid
|
||||
time_field: datetimefield
|
||||
|
||||
.. note::
|
||||
|
||||
For OpenSearch indexes that are password protect, a RFC1738 URL can be used as follows:
|
||||
|
||||
``data: http://username:password@localhost:9200/ne_110m_populated_places_simple``
|
||||
|
||||
To further conceal authentication credentials, environment variables can be used:
|
||||
|
||||
``data: http://${MY_USERNAME}:${MY_PASSWORD}@localhost:9200/ne_110m_populated_places_simple``
|
||||
|
||||
The OpenSearch provider also has the support for the CQL queries as indicated in the table above.
|
||||
|
||||
.. seealso::
|
||||
:ref:`cql` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries.
|
||||
|
||||
.. _Oracle:
|
||||
|
||||
Oracle
|
||||
@@ -420,7 +482,7 @@ Configured using environment variables.
|
||||
export ORACLE_POOL_MAX=10
|
||||
|
||||
|
||||
The ``ORACLE_POOL_MIN`` and ``ORACLE_POOL_MAX`` environment variables are used to trigger session pool creation in the Oracle Provider and the ``DatabaseConnection`` class. See https://python-oracledb.readthedocs.io/en/latest/api_manual/module.html#oracledb.create_pool for documentation of the ``create_pool`` function.
|
||||
The ``ORACLE_POOL_MIN`` and ``ORACLE_POOL_MAX`` environment variables are used to trigger session pool creation in the Oracle Provider and the ``DatabaseConnection`` class. Supports auth via user + password or wallet. For an example of the configuration see above at Oracle - Connection. See https://python-oracledb.readthedocs.io/en/latest/api_manual/module.html#oracledb.create_pool for documentation of the ``create_pool`` function.
|
||||
|
||||
If none or only one of the environment variables is set, session pooling will not be activated and standalone connections are established at every request.
|
||||
|
||||
@@ -432,6 +494,40 @@ useful e.g. for authorization at row level or manipulation of the explain plan w
|
||||
|
||||
An example an more information about that feature you can find in the test class in tests/test_oracle_provider.py.
|
||||
|
||||
.. _Parquet:
|
||||
|
||||
Parquet
|
||||
^^^^^^^
|
||||
|
||||
.. note::
|
||||
Requires Python package pyarrow
|
||||
|
||||
To publish a GeoParquet file (with a geometry column) the geopandas package is also required.
|
||||
|
||||
.. note::
|
||||
Reading data directly from a public s3 bucket is also supported.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
providers:
|
||||
- type: feature
|
||||
name: Parquet
|
||||
data:
|
||||
source: ./tests/data/parquet/random.parquet
|
||||
id_field: id
|
||||
time_field: time
|
||||
x_field:
|
||||
- minlon
|
||||
- maxlon
|
||||
y_field:
|
||||
- minlat
|
||||
- maxlat
|
||||
|
||||
For GeoParquet data, the `x_field` and `y_field` must be specified in the provider definition,
|
||||
and they must be arrays of two column names that contain the x and y coordinates of the
|
||||
bounding box of each geometry. If the geometries in the data are all points, the `x_field` and `y_field`
|
||||
can be strings instead of arrays and refer to a single column each.
|
||||
|
||||
.. _PostgreSQL:
|
||||
|
||||
PostgreSQL
|
||||
|
||||
@@ -51,7 +51,7 @@ Currently supported style files (`options.style`):
|
||||
.. code-block:: yaml
|
||||
|
||||
providers:
|
||||
- type: map
|
||||
- type: map
|
||||
name: MapScript
|
||||
data: /path/to/data.shp
|
||||
options:
|
||||
@@ -59,7 +59,7 @@ Currently supported style files (`options.style`):
|
||||
layer: foo_name
|
||||
style: ./foo.sld
|
||||
format:
|
||||
name: png
|
||||
name: png
|
||||
mimetype: image/png
|
||||
|
||||
WMSFacade
|
||||
@@ -71,14 +71,15 @@ required. An optional style name can be defined via `options.style`.
|
||||
.. code-block:: yaml
|
||||
|
||||
providers:
|
||||
- type: map
|
||||
- type: map
|
||||
name: WMSFacade
|
||||
data: https://demo.mapserver.org/cgi-bin/msautotest
|
||||
options:
|
||||
layer: world_latlong
|
||||
style: default
|
||||
version: 1.3.0
|
||||
format:
|
||||
name: png
|
||||
name: png
|
||||
mimetype: image/png
|
||||
|
||||
|
||||
|
||||
@@ -14,15 +14,47 @@ The pygeoapi offers two processes: a default ``hello-world`` process which allow
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
The below configuration is an example of a process defined within the pygeoapi internal plugin registry:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
processes:
|
||||
|
||||
# enabled by default
|
||||
# enabled by default
|
||||
hello-world:
|
||||
processor:
|
||||
name: HelloWorld
|
||||
|
||||
The below configuration is an example of a process defined as part of a custom Python process:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
processes:
|
||||
# enabled by default
|
||||
hello-world:
|
||||
processor:
|
||||
# refer to a process in the standard PYTHONPATH
|
||||
# e.g. my_package/my_module/my_file.py (class MyProcess)
|
||||
# the MyProcess class must subclass from pygeoapi.process.base.BaseProcessor
|
||||
name: my_package.my_module.my_file.MyProcess
|
||||
|
||||
See :ref:`example-custom-pygeoapi-processing-plugin` for processing plugin examples.
|
||||
|
||||
Processing and response handling
|
||||
--------------------------------
|
||||
|
||||
pygeoapi processing plugins must return a tuple of media type and native outputs. Multipart
|
||||
responses are not supported at this time, and it is up to the process plugin implementor to return a single
|
||||
payload defining multiple artifacts (or references to them).
|
||||
|
||||
By default (or via the OGC API - Processes ``response: raw`` execution parameter), pygeoapi provides
|
||||
processing responses in their native encoding and media type, as defined by a given
|
||||
plugin (which needs to set the response content type and payload accordingly).
|
||||
|
||||
pygeoapi also supports a JSON-based response type (via the OGC API - Processes ``response: document``
|
||||
execution parameter). When this mode is requested, the response will always be a JSON encoding, embedding
|
||||
the resulting payload (part of which may be Base64 encoded for binary data, for example).
|
||||
|
||||
|
||||
Asynchronous support
|
||||
--------------------
|
||||
|
||||
@@ -33,15 +65,27 @@ an asynchronous design pattern. This means that when a job is submitted in asyn
|
||||
mode, the server responds immediately with a reference to the job, which allows the client
|
||||
to periodically poll the server for the processing status of a given job.
|
||||
|
||||
pygeoapi provides asynchronous support by providing a 'manager' concept which, well,
|
||||
In keeping with the OGC API - Processes specification, asynchronous process execution
|
||||
can be requested by including the ``Prefer: respond-async`` HTTP header in the request.
|
||||
|
||||
Job management is required for asynchronous functionality.
|
||||
|
||||
Job management
|
||||
--------------
|
||||
|
||||
pygeoapi provides job management by providing a 'manager' concept which, well,
|
||||
manages job execution. The manager concept is implemented as part of the pygeoapi
|
||||
:ref:`plugins` architecture. pygeoapi provides a default manager implementation
|
||||
based on `TinyDB`_ for simplicity. Custom manager plugins can be developed for more
|
||||
advanced job management capabilities (e.g. Kubernetes, databases, etc.).
|
||||
|
||||
In keeping with the OGC API - Processes specification, asynchronous process execution
|
||||
can be requested by including the ``Prefer: respond-async`` HTTP header in the request
|
||||
Job managers
|
||||
------------
|
||||
|
||||
TinyDB
|
||||
^^^^^^
|
||||
|
||||
TinyDB is the default job manager for pygeoapi when enabled.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
@@ -52,11 +96,12 @@ can be requested by including the ``Prefer: respond-async`` HTTP header in the r
|
||||
output_dir: /tmp/
|
||||
|
||||
MongoDB
|
||||
--------------------
|
||||
As an alternative to the default a manager employing `MongoDB`_ can be used.
|
||||
The connection to an installed `MongoDB`_ instance must be provided in the configuration.
|
||||
`MongoDB`_ uses the localhost and port 27017 by default. Jobs are stored in a collection named
|
||||
job_manager_pygeoapi.
|
||||
^^^^^^^
|
||||
|
||||
As an alternative to the default, a manager employing `MongoDB`_ can be used.
|
||||
The connection to a `MongoDB`_ instance must be provided in the configuration.
|
||||
`MongoDB`_ uses ``localhost`` and port ``27017`` by default. Jobs are stored in a collection named
|
||||
``job_manager_pygeoapi``.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
@@ -66,11 +111,34 @@ job_manager_pygeoapi.
|
||||
connection: mongodb://host:port
|
||||
output_dir: /tmp/
|
||||
|
||||
PostgreSQL
|
||||
^^^^^^^^^^
|
||||
|
||||
As another alternative to the default, a manager employing `PostgreSQL`_ can be used.
|
||||
The connection to a `PostgreSQL`_ database must be provided in the configuration.
|
||||
`PostgreSQL`_ uses ``localhost`` and port ``5432`` by default. Jobs are stored in a table named ``jobs``.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
server:
|
||||
manager:
|
||||
name: PostgreSQL
|
||||
connection:
|
||||
host: localhost
|
||||
port: 5432
|
||||
database: test
|
||||
user: postgres
|
||||
password: ${POSTGRESQL_PASSWORD:-postgres}
|
||||
# Alternative accepted connection definition:
|
||||
# connection: postgresql://postgres:postgres@localhost:5432/test
|
||||
# connection: postgresql://postgres:${POSTGRESQL_PASSWORD:-postgres}@localhost:5432/test
|
||||
output_dir: /tmp
|
||||
|
||||
|
||||
Putting it all together
|
||||
-----------------------
|
||||
|
||||
To summarize how pygeoapi processes and managers work together::
|
||||
To summarize how pygeoapi processes and managers work together:
|
||||
|
||||
* process plugins implement the core processing / workflow functionality
|
||||
* manager plugins control and manage how processes are executed
|
||||
|
||||
@@ -106,8 +106,8 @@ Following block shows how to configure pygeoapi to read Mapbox vector tiles from
|
||||
zoom:
|
||||
min: 0
|
||||
max: 15
|
||||
schemes:
|
||||
- WebMercatorQuad # this option is needed in the MVT-proxy provider
|
||||
schemes:
|
||||
- WebMercatorQuad # this option is needed in the MVT-proxy provider
|
||||
format:
|
||||
name: pbf
|
||||
mimetype: application/vnd.mapbox-vector-tile
|
||||
@@ -124,8 +124,8 @@ Following code block shows how to configure pygeoapi to read Mapbox vector tiles
|
||||
zoom:
|
||||
min: 0
|
||||
max: 15
|
||||
schemes:
|
||||
- WebMercatorQuad
|
||||
schemes:
|
||||
- WebMercatorQuad
|
||||
format:
|
||||
name: pbf
|
||||
mimetype: application/vnd.mapbox-vector-tile
|
||||
|
||||
@@ -11,6 +11,13 @@ Requirements and dependencies
|
||||
|
||||
pygeoapi runs on Python 3.
|
||||
|
||||
.. note::
|
||||
|
||||
The exact Python version requirements are aligned with the version of Python on the pygeoapi supported Ubuntu
|
||||
operating system version. For example, as of 2024-07, the supported version of Python is bound to Ubuntu 22.04
|
||||
(Jammy) which supports Python 3.10. Ensure you have a Python version that is compatible with the current Ubuntu
|
||||
version that is specified in pygeoapi's `Dockerfile`_.
|
||||
|
||||
Core dependencies are included as part of a given pygeoapi installation procedure. More specific requirements
|
||||
details are described below depending on the platform.
|
||||
|
||||
@@ -32,7 +39,7 @@ For developers and the truly impatient
|
||||
vi example-config.yml # edit as required
|
||||
export PYGEOAPI_CONFIG=example-config.yml
|
||||
export PYGEOAPI_OPENAPI=example-openapi.yml
|
||||
pygeoapi openapi generate $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI
|
||||
pygeoapi openapi generate $PYGEOAPI_CONFIG --output-file $PYGEOAPI_OPENAPI
|
||||
pygeoapi serve
|
||||
curl http://localhost:5000
|
||||
|
||||
@@ -142,3 +149,4 @@ onto your system.
|
||||
|
||||
|
||||
.. _`Docker image`: https://github.com/geopython/pygeoapi/pkgs/container/pygeoapi
|
||||
.. _`Dockerfile`: https://github.com/geopython/pygeoapi/blob/master/Dockerfile
|
||||
|
||||
@@ -14,12 +14,12 @@ Features
|
||||
* OGC API - Features
|
||||
* OGC API - Environmental Data Retrieval
|
||||
* OGC API - Tiles
|
||||
* OGC API - Processes
|
||||
|
||||
* additionally implements
|
||||
|
||||
* OGC API - Coverages
|
||||
* OGC API - Maps
|
||||
* OGC API - Processes
|
||||
* OGC API - Records
|
||||
* SpatioTemporal Asset Library
|
||||
|
||||
@@ -52,7 +52,7 @@ Standards are at the core of pygeoapi. Below is the project's standards support
|
||||
`OGC API - Coverages`_,Implementing
|
||||
`OGC API - Maps`_,Implementing
|
||||
`OGC API - Tiles`_,Reference Implementation
|
||||
`OGC API - Processes`_,Implementing
|
||||
`OGC API - Processes`_,Compliant
|
||||
`OGC API - Records`_,Implementing
|
||||
`OGC API - Environmental Data Retrieval`_,Reference Implementation
|
||||
`SpatioTemporal Asset Catalog`_,Implementing
|
||||
|
||||
@@ -240,15 +240,16 @@ The below template provides a minimal example (let's call the file ``mycoolraste
|
||||
super().__init__(provider_def)
|
||||
self.num_bands = 4
|
||||
self.axes = ['Lat', 'Long']
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
# generate a JSON Schema of coverage band metadata
|
||||
return {
|
||||
self._fields = {
|
||||
'b1': {
|
||||
'type': 'number'
|
||||
}
|
||||
}
|
||||
return self._fields
|
||||
|
||||
def query(self, bands=[], subsets={}, format_='json', **kwargs):
|
||||
# process bands and subsets parameters
|
||||
@@ -272,6 +273,8 @@ implementation.
|
||||
|
||||
Each base class documents the functions, arguments and return types required for implementation.
|
||||
|
||||
.. _example-custom-pygeoapi-processing-plugin:
|
||||
|
||||
Example: custom pygeoapi processing plugin
|
||||
------------------------------------------
|
||||
|
||||
|
||||
@@ -14,4 +14,5 @@ as required.
|
||||
The following projects provide security frameworks atop pygeoapi:
|
||||
|
||||
* `fastgeoapi <https://github.com/geobeyond/fastgeoapi>`_
|
||||
* `pygeoapi-auth <https://github.com/cartologic/pygeoapi-auth>`_
|
||||
* `pygeoapi-auth-deployment <https://github.com/cartologic/pygeoapi-auth-deployment>`_
|
||||
* `pygeoapi-auth <https://github.com/geopython/pygeoapi-auth>`_ (Python package for use along with pygeoapi-auth-deployment)
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# =================================================================
|
||||
#
|
||||
# Authors: Tom Kralidis <tomkralidis@gmail.com>
|
||||
#
|
||||
# Copyright (c) 2020 Tom Kralidis
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person
|
||||
# obtaining a copy of this software and associated documentation
|
||||
# files (the "Software"), to deal in the Software without
|
||||
# restriction, including without limitation the rights to use,
|
||||
# copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the
|
||||
# Software is furnished to do so, subject to the following
|
||||
# conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
# OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
# =================================================================
|
||||
|
||||
server:
|
||||
bind:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
url: https://geo.speckle.systems
|
||||
mimetype: application/json; charset=UTF-8
|
||||
encoding: utf-8
|
||||
gzip: false
|
||||
languages:
|
||||
# First language is the default language
|
||||
- en-US
|
||||
- fr-CA
|
||||
# cors: true
|
||||
pretty_print: true
|
||||
limit: 10000
|
||||
# templates:
|
||||
# path: /path/to/Jinja2/templates
|
||||
# static: /path/to/static/folder # css/js/img
|
||||
map:
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
# manager:
|
||||
# name: TinyDB
|
||||
# connection: /tmp/pygeoapi-process-manager.db
|
||||
# output_dir: /tmp/
|
||||
# ogc_schemas_location: /opt/schemas.opengis.net
|
||||
|
||||
logging:
|
||||
level: ERROR
|
||||
#logfile: /tmp/pygeoapi.log
|
||||
|
||||
metadata:
|
||||
identification:
|
||||
title:
|
||||
en: Speckle pygeoapi instance
|
||||
description:
|
||||
en: pygeoapi provides an API to geospatial data
|
||||
keywords:
|
||||
en:
|
||||
- geospatial
|
||||
- data
|
||||
- api
|
||||
keywords_type: theme
|
||||
terms_of_service: https://creativecommons.org/licenses/by/4.0/
|
||||
url: https://example.org
|
||||
license:
|
||||
name: CC-BY 4.0 license
|
||||
url: https://creativecommons.org/licenses/by/4.0/
|
||||
provider:
|
||||
name: Organization Name
|
||||
url: https://pygeoapi.io
|
||||
contact:
|
||||
name: Lastname, Firstname
|
||||
position: Position Title
|
||||
address: Mailing Address
|
||||
city: City
|
||||
stateorprovince: Administrative Area
|
||||
postalcode: Zip or Postal Code
|
||||
country: Country
|
||||
phone: +xx-xxx-xxx-xxxx
|
||||
fax: +xx-xxx-xxx-xxxx
|
||||
email: you@example.org
|
||||
url: https://app.speckle.systems/
|
||||
hours: Mo-Fr 08:00-17:00
|
||||
instructions: During hours of service. Off on weekends.
|
||||
role: pointOfContact
|
||||
|
||||
resources:
|
||||
speckle:
|
||||
type: collection
|
||||
title:
|
||||
en: Speckle data
|
||||
description:
|
||||
en: Latest version of Speckle Model data
|
||||
keywords:
|
||||
en:
|
||||
- 3d data
|
||||
- speckle
|
||||
links:
|
||||
- type: text/html
|
||||
rel: canonical
|
||||
title: information
|
||||
href: https://speckle.systems
|
||||
hreflang: en-US
|
||||
extents:
|
||||
spatial:
|
||||
bbox: [-180,-90,180,90]
|
||||
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 #https://www.opengis.net/def/crs/OGC/1.3/AUTO42001?lat=0&lon=0 # projected CRS
|
||||
temporal:
|
||||
begin: 2011-11-11T11:11:11Z
|
||||
end: null # or empty (either means open ended)
|
||||
providers:
|
||||
- type: feature
|
||||
name: Speckle
|
||||
data: # some data
|
||||
id_field: id
|
||||
title_field: id
|
||||
|
||||
hello-world:
|
||||
type: process
|
||||
processor:
|
||||
name: HelloWorld
|
||||
@@ -0,0 +1,748 @@
|
||||
# Arabic translations for PROJECT.
|
||||
# Copyright (C) 2024 OSGeo
|
||||
# This file is distributed under the same license as the pygeoapi project.
|
||||
# FIRST AUTHOR Youssef Harby, 2024.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 0.0.19\n"
|
||||
"Report-Msgid-Bugs-To: pygeoapi@lists.osgeo.org\n"
|
||||
"POT-Creation-Date: 2024-11-19 23:22+0200\n"
|
||||
"PO-Revision-Date: 2024-11-19 23:22+0200\n"
|
||||
"Last-Translator: Youssef Harby <me@youssefharby.com>\n"
|
||||
"Language: ar\n"
|
||||
"Language-Team: ar <LL@li.org>\n"
|
||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : "
|
||||
"n%100>=3 && n%100<=10 ? 3 : n%100>=0 && n%100<=2 ? 4 : 5);\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.13.0\n"
|
||||
|
||||
#: build/lib/pygeoapi/templates/_base.html:62
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:2
|
||||
#: pygeoapi/templates/_base.html:67 pygeoapi/templates/landing_page.html:2
|
||||
msgid "Home"
|
||||
msgstr "الصفحة الرئيسية"
|
||||
|
||||
#: build/lib/pygeoapi/templates/_base.html:70
|
||||
#: build/lib/pygeoapi/templates/_base.html:78 pygeoapi/templates/_base.html:75
|
||||
#: pygeoapi/templates/_base.html:83
|
||||
msgid "json"
|
||||
msgstr "json"
|
||||
|
||||
#: build/lib/pygeoapi/templates/_base.html:73
|
||||
#: build/lib/pygeoapi/templates/_base.html:81 pygeoapi/templates/_base.html:78
|
||||
#: pygeoapi/templates/_base.html:86
|
||||
msgid "jsonld"
|
||||
msgstr "jsonld"
|
||||
|
||||
#: build/lib/pygeoapi/templates/_base.html:100
|
||||
#: pygeoapi/templates/_base.html:107
|
||||
msgid "Powered by "
|
||||
msgstr "مدعوم بواسطة "
|
||||
|
||||
#: build/lib/pygeoapi/templates/conformance.html:2
|
||||
#: build/lib/pygeoapi/templates/conformance.html:4
|
||||
#: build/lib/pygeoapi/templates/conformance.html:8
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:86
|
||||
#: pygeoapi/templates/conformance.html:2 pygeoapi/templates/conformance.html:4
|
||||
#: pygeoapi/templates/conformance.html:8
|
||||
#: pygeoapi/templates/landing_page.html:95
|
||||
msgid "Conformance"
|
||||
msgstr "التوافق"
|
||||
|
||||
#: build/lib/pygeoapi/templates/exception.html:2
|
||||
#: build/lib/pygeoapi/templates/exception.html:5
|
||||
#: pygeoapi/templates/exception.html:2 pygeoapi/templates/exception.html:5
|
||||
msgid "Exception"
|
||||
msgstr "استثناء"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:25
|
||||
#: pygeoapi/templates/landing_page.html:25
|
||||
msgid "Terms of service"
|
||||
msgstr "شروط الخدمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:38
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:35
|
||||
#: pygeoapi/templates/collections/collection.html:38
|
||||
#: pygeoapi/templates/landing_page.html:35
|
||||
msgid "License"
|
||||
msgstr "الرخصة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:6
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:4
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/rangetype.html:4
|
||||
#: build/lib/pygeoapi/templates/collections/edr/query.html:4
|
||||
#: build/lib/pygeoapi/templates/collections/index.html:2
|
||||
#: build/lib/pygeoapi/templates/collections/index.html:4
|
||||
#: build/lib/pygeoapi/templates/collections/items/index.html:4
|
||||
#: build/lib/pygeoapi/templates/collections/items/item.html:27
|
||||
#: build/lib/pygeoapi/templates/collections/queryables.html:4
|
||||
#: build/lib/pygeoapi/templates/collections/tiles/index.html:4
|
||||
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:4
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:48
|
||||
#: pygeoapi/templates/collections/collection.html:6
|
||||
#: pygeoapi/templates/collections/edr/query.html:4
|
||||
#: pygeoapi/templates/collections/index.html:2
|
||||
#: pygeoapi/templates/collections/index.html:4
|
||||
#: pygeoapi/templates/collections/items/index.html:4
|
||||
#: pygeoapi/templates/collections/items/item.html:27
|
||||
#: pygeoapi/templates/collections/queryables.html:4
|
||||
#: pygeoapi/templates/collections/schema.html:4
|
||||
#: pygeoapi/templates/collections/tiles/index.html:4
|
||||
#: pygeoapi/templates/collections/tiles/metadata.html:4
|
||||
#: pygeoapi/templates/landing_page.html:57
|
||||
#: pygeoapi/templates/stac/collection_base.html:19
|
||||
msgid "Collections"
|
||||
msgstr "المجموعات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:50
|
||||
#: pygeoapi/templates/landing_page.html:59
|
||||
msgid "View the collections in this service"
|
||||
msgstr "عرض المجموعات في هذه الخدمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:56
|
||||
#: pygeoapi/templates/landing_page.html:65
|
||||
msgid "SpatioTemporal Assets"
|
||||
msgstr "الأصول الزمانية والمكانية (STAC)"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:58
|
||||
#: pygeoapi/templates/landing_page.html:67
|
||||
msgid "View the SpatioTemporal Assets in this service"
|
||||
msgstr "عرض الأصول الزمانية والمكانية في هذه الخدمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:64
|
||||
#: build/lib/pygeoapi/templates/processes/index.html:2
|
||||
#: build/lib/pygeoapi/templates/processes/index.html:4
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:4
|
||||
#: pygeoapi/templates/landing_page.html:73
|
||||
#: pygeoapi/templates/processes/index.html:2
|
||||
#: pygeoapi/templates/processes/index.html:4
|
||||
#: pygeoapi/templates/processes/process.html:4
|
||||
msgid "Processes"
|
||||
msgstr "العمليات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:66
|
||||
#: pygeoapi/templates/landing_page.html:75
|
||||
msgid "View the processes in this service"
|
||||
msgstr "عرض العمليات في هذه الخدمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/index.html:2
|
||||
#: build/lib/pygeoapi/templates/jobs/index.html:4
|
||||
#: build/lib/pygeoapi/templates/jobs/index.html:11
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:4
|
||||
#: build/lib/pygeoapi/templates/jobs/results/index.html:4
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:70
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:76
|
||||
#: pygeoapi/templates/jobs/index.html:2 pygeoapi/templates/jobs/index.html:4
|
||||
#: pygeoapi/templates/jobs/index.html:11 pygeoapi/templates/jobs/job.html:4
|
||||
#: pygeoapi/templates/jobs/results/index.html:4
|
||||
#: pygeoapi/templates/landing_page.html:79
|
||||
#: pygeoapi/templates/processes/process.html:76
|
||||
msgid "Jobs"
|
||||
msgstr "المهام"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:72
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:77
|
||||
#: pygeoapi/templates/landing_page.html:81
|
||||
#: pygeoapi/templates/processes/process.html:77
|
||||
msgid "Browse jobs"
|
||||
msgstr "تصفح المهام"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:77
|
||||
#: pygeoapi/templates/landing_page.html:86
|
||||
msgid "API Definition"
|
||||
msgstr "تعريف API"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:79
|
||||
#: pygeoapi/templates/landing_page.html:88
|
||||
msgid "Documentation"
|
||||
msgstr "التوثيق"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:79
|
||||
#: pygeoapi/templates/landing_page.html:88
|
||||
msgid "Swagger UI"
|
||||
msgstr "واجهة Swagger"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:79
|
||||
#: pygeoapi/templates/landing_page.html:88
|
||||
msgid "ReDoc"
|
||||
msgstr "ReDoc"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:82
|
||||
#: pygeoapi/templates/landing_page.html:91
|
||||
msgid "OpenAPI Document"
|
||||
msgstr "وثيقة OpenAPI"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:88
|
||||
#: pygeoapi/templates/landing_page.html:97
|
||||
msgid "View the conformance classes of this service"
|
||||
msgstr "عرض فئات التوافق لهذه الخدمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:95
|
||||
#: pygeoapi/templates/landing_page.html:110
|
||||
msgid "Provider"
|
||||
msgstr "المزود"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:104
|
||||
#: pygeoapi/templates/landing_page.html:119
|
||||
msgid "Contact point"
|
||||
msgstr "نقطة الاتصال"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:107
|
||||
#: pygeoapi/templates/landing_page.html:122
|
||||
msgid "Address"
|
||||
msgstr "العنوان"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:116
|
||||
#: pygeoapi/templates/landing_page.html:131
|
||||
msgid "Email"
|
||||
msgstr "البريد الإلكتروني"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:119
|
||||
#: pygeoapi/templates/landing_page.html:134
|
||||
msgid "Telephone"
|
||||
msgstr "الهاتف"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:123
|
||||
#: pygeoapi/templates/landing_page.html:138
|
||||
msgid "Fax"
|
||||
msgstr "الفاكس"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:127
|
||||
#: pygeoapi/templates/landing_page.html:142
|
||||
msgid "Contact URL"
|
||||
msgstr "رابط الاتصال"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:131
|
||||
#: pygeoapi/templates/landing_page.html:146
|
||||
msgid "Hours"
|
||||
msgstr "ساعات العمل"
|
||||
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:135
|
||||
#: pygeoapi/templates/landing_page.html:150
|
||||
msgid "Contact instructions"
|
||||
msgstr "تعليمات الاتصال"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:51
|
||||
#: pygeoapi/templates/collections/collection.html:51
|
||||
msgid "Browse"
|
||||
msgstr "تصفح"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:55
|
||||
#: pygeoapi/templates/collections/collection.html:55
|
||||
msgid "Browse Items"
|
||||
msgstr "تصفح العناصر"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:56
|
||||
#: pygeoapi/templates/collections/collection.html:56
|
||||
msgid "Browse through the items of"
|
||||
msgstr "تصفح عناصر"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:59
|
||||
#: build/lib/pygeoapi/templates/collections/queryables.html:6
|
||||
#: build/lib/pygeoapi/templates/collections/queryables.html:17
|
||||
#: pygeoapi/templates/collections/collection.html:59
|
||||
#: pygeoapi/templates/collections/queryables.html:6
|
||||
#: pygeoapi/templates/collections/queryables.html:17
|
||||
msgid "Queryables"
|
||||
msgstr "قابليات الاستعلام"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:63
|
||||
#: pygeoapi/templates/collections/collection.html:63
|
||||
msgid "Display Queryables"
|
||||
msgstr "عرض قابليات الاستعلام"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:64
|
||||
#: pygeoapi/templates/collections/collection.html:64
|
||||
msgid "Display Queryables of"
|
||||
msgstr "عرض قابليات الاستعلام لـ"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:69
|
||||
#: build/lib/pygeoapi/templates/collections/tiles/index.html:6
|
||||
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:6
|
||||
#: pygeoapi/templates/collections/collection.html:77
|
||||
#: pygeoapi/templates/collections/tiles/index.html:6
|
||||
#: pygeoapi/templates/collections/tiles/metadata.html:6
|
||||
msgid "Tiles"
|
||||
msgstr "البلاطات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:73
|
||||
#: pygeoapi/templates/collections/collection.html:81
|
||||
msgid "Display Tiles"
|
||||
msgstr "عرض البلاطات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:73
|
||||
#: pygeoapi/templates/collections/collection.html:81
|
||||
msgid "Display Tiles of"
|
||||
msgstr "عرض البلاطات لـ"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:80
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:50
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:78
|
||||
#: pygeoapi/templates/collections/collection.html:107
|
||||
#: pygeoapi/templates/collections/items/item.html:101
|
||||
#: pygeoapi/templates/jobs/job.html:50
|
||||
#: pygeoapi/templates/processes/process.html:78
|
||||
msgid "Links"
|
||||
msgstr "الروابط"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:90
|
||||
#: pygeoapi/templates/collections/collection.html:117
|
||||
msgid "Reference Systems"
|
||||
msgstr "أنظمة الإسناد المرجعي"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/collection.html:98
|
||||
#: pygeoapi/templates/collections/collection.html:125
|
||||
msgid "Storage CRS"
|
||||
msgstr "نظام الإحداثيات المرجعي للتخزين"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/index.html:12
|
||||
#: build/lib/pygeoapi/templates/processes/index.html:14
|
||||
#: build/lib/pygeoapi/templates/stac/catalog.html:17
|
||||
#: build/lib/pygeoapi/templates/stac/collection.html:17
|
||||
#: pygeoapi/templates/collections/index.html:12
|
||||
#: pygeoapi/templates/collections/index.html:39
|
||||
#: pygeoapi/templates/processes/index.html:14
|
||||
#: pygeoapi/templates/stac/catalog.html:17
|
||||
#: pygeoapi/templates/stac/collection.html:17
|
||||
msgid "Name"
|
||||
msgstr "الاسم"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/index.html:13
|
||||
#: build/lib/pygeoapi/templates/stac/catalog.html:18
|
||||
#: pygeoapi/templates/stac/catalog.html:18
|
||||
msgid "Type"
|
||||
msgstr "النوع"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/index.html:14
|
||||
#: build/lib/pygeoapi/templates/processes/index.html:15
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:26
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:56
|
||||
#: build/lib/pygeoapi/templates/stac/collection.html:18
|
||||
#: pygeoapi/templates/collections/index.html:13
|
||||
#: pygeoapi/templates/collections/index.html:40
|
||||
#: pygeoapi/templates/processes/index.html:15
|
||||
#: pygeoapi/templates/processes/process.html:26
|
||||
#: pygeoapi/templates/processes/process.html:56
|
||||
#: pygeoapi/templates/stac/collection.html:18
|
||||
#: pygeoapi/templates/tilematrixsets/index.html:16
|
||||
msgid "Description"
|
||||
msgstr "الوصف"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:11
|
||||
msgid "Coverage domain set"
|
||||
msgstr "مجموعة مجال التغطية"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:12
|
||||
msgid "Axis labels"
|
||||
msgstr "تسميات المحاور"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:18
|
||||
msgid "Extent"
|
||||
msgstr "النطاق"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:24
|
||||
msgid "Coordinate reference system"
|
||||
msgstr "نظام الإحداثيات المرجعي"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:26
|
||||
#: build/lib/pygeoapi/templates/stac/catalog.html:20
|
||||
#: build/lib/pygeoapi/templates/stac/item.html:34
|
||||
#: pygeoapi/templates/stac/catalog.html:20
|
||||
#: pygeoapi/templates/stac/collection_base.html:34
|
||||
#: pygeoapi/templates/stac/item.html:34
|
||||
msgid "Size"
|
||||
msgstr "الحجم"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:28
|
||||
msgid "width"
|
||||
msgstr "العرض"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:29
|
||||
msgid "height"
|
||||
msgstr "الارتفاع"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:31
|
||||
msgid "Resolution"
|
||||
msgstr "الدقة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:33
|
||||
msgid "x"
|
||||
msgstr "x"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:34
|
||||
msgid "y"
|
||||
msgstr "y"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/rangetype.html:11
|
||||
msgid "Coverage range type"
|
||||
msgstr "نوع نطاق التغطية"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/coverage/rangetype.html:12
|
||||
msgid "Fields"
|
||||
msgstr "الحقول"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/edr/query.html:11
|
||||
#: build/lib/pygeoapi/templates/collections/items/index.html:11
|
||||
#: build/lib/pygeoapi/templates/collections/items/item.html:33
|
||||
#: pygeoapi/templates/collections/items/index.html:11
|
||||
#: pygeoapi/templates/collections/items/item.html:33
|
||||
msgid "Items"
|
||||
msgstr "العناصر"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/items/index.html:25
|
||||
#: pygeoapi/templates/collections/items/index.html:38
|
||||
#: pygeoapi/templates/collections/items/index.html:142
|
||||
msgid "Items in this collection"
|
||||
msgstr "العناصر في هذه المجموعة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/items/index.html:38
|
||||
#: pygeoapi/templates/collections/items/index.html:51
|
||||
msgid "Warning: Higher limits not recommended!"
|
||||
msgstr "تحذير: لا يُنصح بالحدود الأعلى!"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/items/index.html:43
|
||||
#: pygeoapi/templates/collections/items/index.html:44
|
||||
#: pygeoapi/templates/jobs/index.html:53
|
||||
msgid "Limit"
|
||||
msgstr "الحد"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/items/index.html:45
|
||||
#: pygeoapi/templates/collections/items/index.html:46
|
||||
#: pygeoapi/templates/jobs/index.html:55
|
||||
msgid "default"
|
||||
msgstr "افتراضي"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/items/index.html:66
|
||||
#: build/lib/pygeoapi/templates/collections/items/item.html:62
|
||||
#: pygeoapi/templates/collections/items/index.html:68
|
||||
#: pygeoapi/templates/collections/items/item.html:62
|
||||
#: pygeoapi/templates/jobs/index.html:76
|
||||
msgid "Prev"
|
||||
msgstr "السابق"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/items/index.html:68
|
||||
#: build/lib/pygeoapi/templates/collections/items/item.html:64
|
||||
#: pygeoapi/templates/collections/items/index.html:70
|
||||
#: pygeoapi/templates/collections/items/item.html:64
|
||||
#: pygeoapi/templates/jobs/index.html:78
|
||||
msgid "Next"
|
||||
msgstr "التالي"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/items/index.html:139
|
||||
#: pygeoapi/templates/collections/edr/query.html:37
|
||||
#: pygeoapi/templates/collections/items/index.html:147
|
||||
msgid "No items"
|
||||
msgstr "لا توجد عناصر"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/items/item.html:77
|
||||
#: build/lib/pygeoapi/templates/stac/item.html:58
|
||||
#: pygeoapi/templates/collections/items/item.html:77
|
||||
#: pygeoapi/templates/stac/collection_base.html:58
|
||||
#: pygeoapi/templates/stac/item.html:58
|
||||
msgid "Property"
|
||||
msgstr "الخاصية"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/items/item.html:78
|
||||
#: build/lib/pygeoapi/templates/stac/item.html:59
|
||||
#: pygeoapi/templates/collections/items/item.html:78
|
||||
#: pygeoapi/templates/stac/collection_base.html:59
|
||||
#: pygeoapi/templates/stac/item.html:59
|
||||
msgid "Value"
|
||||
msgstr "القيمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/tiles/index.html:31
|
||||
#: pygeoapi/templates/collections/tiles/index.html:31
|
||||
msgid "Tile Matrix Set"
|
||||
msgstr "مجموعة مصفوفة البلاطات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/tiles/index.html:43
|
||||
#: pygeoapi/templates/collections/tiles/index.html:42
|
||||
msgid "Metadata"
|
||||
msgstr "البيانات الوصفية"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:18
|
||||
msgid "Tiles metadata"
|
||||
msgstr "بيانات وصفية للبلاطات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:18
|
||||
msgid "format"
|
||||
msgstr "التنسيق"
|
||||
|
||||
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:19
|
||||
msgid "Tileset"
|
||||
msgstr "مجموعة البلاطات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/index.html:14
|
||||
#: pygeoapi/templates/jobs/index.html:14
|
||||
msgid "Job ID"
|
||||
msgstr "معرف المهمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/index.html:15
|
||||
#: pygeoapi/templates/jobs/index.html:15
|
||||
msgid "Process ID"
|
||||
msgstr "معرف العملية"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/index.html:16
|
||||
#: pygeoapi/templates/jobs/index.html:16
|
||||
msgid "Start"
|
||||
msgstr "بداية"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/index.html:17
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:37
|
||||
#: pygeoapi/templates/jobs/index.html:17 pygeoapi/templates/jobs/job.html:37
|
||||
msgid "Duration"
|
||||
msgstr "المدة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/index.html:18
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:17
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:35
|
||||
#: pygeoapi/templates/jobs/index.html:18 pygeoapi/templates/jobs/job.html:17
|
||||
#: pygeoapi/templates/jobs/job.html:35
|
||||
msgid "Progress"
|
||||
msgstr "التقدم"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/index.html:19
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:16
|
||||
#: pygeoapi/templates/jobs/index.html:19 pygeoapi/templates/jobs/job.html:16
|
||||
msgid "Status"
|
||||
msgstr "الحالة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/index.html:20
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:21
|
||||
#: pygeoapi/templates/jobs/index.html:20 pygeoapi/templates/jobs/job.html:21
|
||||
msgid "Message"
|
||||
msgstr "الرسالة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:2
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:10
|
||||
#: pygeoapi/templates/jobs/job.html:2 pygeoapi/templates/jobs/job.html:10
|
||||
msgid "Job status"
|
||||
msgstr "حالة المهمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:26
|
||||
#: pygeoapi/templates/jobs/job.html:26
|
||||
msgid "Parameters"
|
||||
msgstr "المعلمات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:45
|
||||
#: pygeoapi/templates/jobs/job.html:45
|
||||
msgid "Started processing"
|
||||
msgstr "بدأت المعالجة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/job.html:47
|
||||
#: pygeoapi/templates/jobs/job.html:47
|
||||
msgid "Finished processing"
|
||||
msgstr "انتهت المعالجة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/results/index.html:2
|
||||
#: pygeoapi/templates/jobs/results/index.html:2
|
||||
msgid "Job result"
|
||||
msgstr "نتيجة المهمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/results/index.html:6
|
||||
#: pygeoapi/templates/jobs/results/index.html:6
|
||||
msgid "Results"
|
||||
msgstr "النتائج"
|
||||
|
||||
#: build/lib/pygeoapi/templates/jobs/results/index.html:10
|
||||
#: pygeoapi/templates/jobs/results/index.html:10
|
||||
msgid "Results of job"
|
||||
msgstr "نتائج المهمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/processes/index.html:8
|
||||
#: pygeoapi/templates/processes/index.html:8
|
||||
msgid "Processes in this service"
|
||||
msgstr "العمليات في هذه الخدمة"
|
||||
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:20
|
||||
#: pygeoapi/templates/processes/process.html:20
|
||||
msgid "Inputs"
|
||||
msgstr "المدخلات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:23
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:54
|
||||
#: pygeoapi/templates/processes/process.html:23
|
||||
#: pygeoapi/templates/processes/process.html:54
|
||||
msgid "Id"
|
||||
msgstr "معرف"
|
||||
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:24
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:55
|
||||
#: pygeoapi/templates/processes/process.html:24
|
||||
#: pygeoapi/templates/processes/process.html:55
|
||||
#: pygeoapi/templates/tilematrixsets/index.html:15
|
||||
msgid "Title"
|
||||
msgstr "العنوان"
|
||||
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:25
|
||||
#: pygeoapi/templates/processes/process.html:25
|
||||
msgid "Data Type"
|
||||
msgstr "نوع البيانات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:51
|
||||
#: pygeoapi/templates/processes/process.html:51
|
||||
msgid "Outputs"
|
||||
msgstr "المخرجات"
|
||||
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:71
|
||||
#: pygeoapi/templates/processes/process.html:71
|
||||
msgid "Execution modes"
|
||||
msgstr "أنماط التنفيذ"
|
||||
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:73
|
||||
#: pygeoapi/templates/processes/process.html:73
|
||||
msgid "Synchronous"
|
||||
msgstr "متزامن"
|
||||
|
||||
#: build/lib/pygeoapi/templates/processes/process.html:74
|
||||
#: pygeoapi/templates/processes/process.html:74
|
||||
msgid "Asynchronous"
|
||||
msgstr "غير متزامن"
|
||||
|
||||
#: build/lib/pygeoapi/templates/stac/catalog.html:4
|
||||
#: build/lib/pygeoapi/templates/stac/collection.html:2
|
||||
#: build/lib/pygeoapi/templates/stac/collection.html:4
|
||||
#: build/lib/pygeoapi/templates/stac/item.html:4
|
||||
#: pygeoapi/templates/stac/catalog.html:4
|
||||
#: pygeoapi/templates/stac/collection.html:2
|
||||
#: pygeoapi/templates/stac/collection.html:4
|
||||
#: pygeoapi/templates/stac/collection_base.html:4
|
||||
#: pygeoapi/templates/stac/item.html:4
|
||||
msgid "SpatioTemporal Asset Catalog"
|
||||
msgstr "كتالوج الأصول الزمانية والمكانية (STAC)"
|
||||
|
||||
#: build/lib/pygeoapi/templates/stac/catalog.html:19
|
||||
#: pygeoapi/templates/stac/catalog.html:19
|
||||
msgid "Last modified"
|
||||
msgstr "آخر تعديل"
|
||||
|
||||
#: build/lib/pygeoapi/templates/stac/collection.html:9
|
||||
#: pygeoapi/templates/stac/collection.html:9
|
||||
msgid "STAC Version"
|
||||
msgstr "إصدار STAC"
|
||||
|
||||
#: build/lib/pygeoapi/templates/stac/item.html:19
|
||||
#: pygeoapi/templates/stac/item.html:19
|
||||
msgid "Item"
|
||||
msgstr "العنصر"
|
||||
|
||||
#: build/lib/pygeoapi/templates/stac/item.html:28
|
||||
#: pygeoapi/templates/stac/collection_base.html:28
|
||||
#: pygeoapi/templates/stac/item.html:28
|
||||
msgid "Assets"
|
||||
msgstr "الأصول"
|
||||
|
||||
#: build/lib/pygeoapi/templates/stac/item.html:32
|
||||
#: pygeoapi/templates/landing_page.html:45
|
||||
#: pygeoapi/templates/stac/collection_base.html:32
|
||||
#: pygeoapi/templates/stac/item.html:32
|
||||
msgid "URL"
|
||||
msgstr "الرابط"
|
||||
|
||||
#: build/lib/pygeoapi/templates/stac/item.html:33
|
||||
#: pygeoapi/templates/stac/collection_base.html:33
|
||||
#: pygeoapi/templates/stac/item.html:33
|
||||
msgid "Last Modified"
|
||||
msgstr "آخر تعديل"
|
||||
|
||||
#: build/lib/pygeoapi/templates/stac/item.html:64
|
||||
#: pygeoapi/templates/stac/collection_base.html:64
|
||||
#: pygeoapi/templates/stac/item.html:64
|
||||
msgid "id"
|
||||
msgstr "معرف"
|
||||
|
||||
#: pygeoapi/templates/_base.html:2
|
||||
msgid "text_direction"
|
||||
msgstr "rtl"
|
||||
|
||||
#: pygeoapi/templates/_base.html:47
|
||||
msgid "Contact"
|
||||
msgstr "الاتصال"
|
||||
|
||||
#: pygeoapi/templates/_base.html:51
|
||||
msgid "Admin"
|
||||
msgstr "الإدارة"
|
||||
|
||||
#: pygeoapi/templates/landing_page.html:101
|
||||
msgid "Tile Matrix Sets"
|
||||
msgstr "مجموعات مصفوفة البلاطات"
|
||||
|
||||
#: pygeoapi/templates/landing_page.html:103
|
||||
msgid "View the Tile Matrix Sets available on this service"
|
||||
msgstr "عرض مجموعات مصفوفة البلاطات المتاحة في هذه الخدمة"
|
||||
|
||||
#: pygeoapi/templates/collections/collection.html:67
|
||||
#: pygeoapi/templates/collections/schema.html:6
|
||||
#: pygeoapi/templates/collections/schema.html:17
|
||||
msgid "Schema"
|
||||
msgstr "المخطط"
|
||||
|
||||
#: pygeoapi/templates/collections/collection.html:71
|
||||
msgid "Display Schema"
|
||||
msgstr "عرض المخطط"
|
||||
|
||||
#: pygeoapi/templates/collections/collection.html:72
|
||||
msgid "Display Schema of"
|
||||
msgstr "عرض مخطط لـ"
|
||||
|
||||
#: pygeoapi/templates/collections/collection.html:128
|
||||
msgid "CRS"
|
||||
msgstr "CRS"
|
||||
|
||||
#: pygeoapi/templates/collections/collection.html:131
|
||||
msgid "Epoch"
|
||||
msgstr "العصر"
|
||||
|
||||
#: pygeoapi/templates/collections/index.html:8
|
||||
msgid "Data collections in this service"
|
||||
msgstr "مجموعات البيانات في هذه الخدمة"
|
||||
|
||||
#: pygeoapi/templates/collections/index.html:35
|
||||
msgid "Record collections in this service"
|
||||
msgstr "مجموعات السجلات في هذه الخدمة"
|
||||
|
||||
#: pygeoapi/templates/collections/edr/query.html:11
|
||||
#, python-format
|
||||
msgid "%(query_type)s"
|
||||
msgstr "%(query_type)s"
|
||||
|
||||
#: pygeoapi/templates/collections/tiles/metadata.html:15
|
||||
msgid "TileJSON"
|
||||
msgstr "TileJSON"
|
||||
|
||||
#: pygeoapi/templates/collections/tiles/metadata.html:17
|
||||
msgid "JSON"
|
||||
msgstr "JSON"
|
||||
|
||||
#: pygeoapi/templates/stac/collection_base.html:68
|
||||
msgid "description"
|
||||
msgstr "الوصف"
|
||||
|
||||
#: pygeoapi/templates/stac/collection_base.html:72
|
||||
msgid "extent"
|
||||
msgstr "النطاق"
|
||||
|
||||
#: pygeoapi/templates/stac/collection_base.html:77
|
||||
msgid "cube:dimensions"
|
||||
msgstr "cube:dimensions"
|
||||
|
||||
#: pygeoapi/templates/stac/collection_base.html:83
|
||||
msgid "cube:variables"
|
||||
msgstr "cube:variables"
|
||||
|
||||
#: pygeoapi/templates/tilematrixsets/index.html:2
|
||||
#: pygeoapi/templates/tilematrixsets/index.html:4
|
||||
#: pygeoapi/templates/tilematrixsets/tilematrixset.html:4
|
||||
msgid "TileMatrixSets"
|
||||
msgstr "مجموعات مصفوفة البلاطات"
|
||||
|
||||
#: pygeoapi/templates/tilematrixsets/index.html:9
|
||||
msgid "Tile matrix sets available in this service"
|
||||
msgstr "مجموعات مصفوفة البلاطات المتاحة في هذه الخدمة"
|
||||
|
||||
#: pygeoapi/templates/tilematrixsets/tilematrixset.html:2
|
||||
msgid "TileMatrixSet"
|
||||
msgstr "مجموعة مصفوفة البلاطات"
|
||||
@@ -19,6 +19,10 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.14.0\n"
|
||||
|
||||
#: pygeoapi/templates/_base.html:2
|
||||
msgid "text_direction"
|
||||
msgstr "ltr"
|
||||
|
||||
#: pygeoapi/templates/_base.html:51
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
@@ -656,3 +660,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
msgstr ""
|
||||
|
||||
msgid "Position"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cube"
|
||||
msgstr ""
|
||||
|
||||
msgid "Area"
|
||||
msgstr ""
|
||||
|
||||
msgid "Corridor"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trajectory"
|
||||
msgstr ""
|
||||
|
||||
msgid "Radius"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locations"
|
||||
msgstr ""
|
||||
|
||||
msgid "Instances"
|
||||
msgstr ""
|
||||
|
||||
@@ -18,6 +18,10 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.9.1\n"
|
||||
|
||||
#: pygeoapi/templates/_base.html:2
|
||||
msgid "text_direction"
|
||||
msgstr "ltr"
|
||||
|
||||
#: build/lib/pygeoapi/templates/_base.html:40
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:2
|
||||
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
||||
@@ -706,3 +710,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
msgstr ""
|
||||
|
||||
msgid "Position"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cube"
|
||||
msgstr ""
|
||||
|
||||
msgid "Area"
|
||||
msgstr ""
|
||||
|
||||
msgid "Corridor"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trajectory"
|
||||
msgstr ""
|
||||
|
||||
msgid "Radius"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locations"
|
||||
msgstr ""
|
||||
|
||||
msgid "Instances"
|
||||
msgstr ""
|
||||
|
||||
@@ -18,6 +18,10 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.9.1\n"
|
||||
|
||||
#: pygeoapi/templates/_base.html:2
|
||||
msgid "text_direction"
|
||||
msgstr "ltr"
|
||||
|
||||
#: build/lib/pygeoapi/templates/_base.html:40
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:2
|
||||
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
||||
@@ -708,3 +712,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
msgstr ""
|
||||
|
||||
msgid "Position"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cube"
|
||||
msgstr ""
|
||||
|
||||
msgid "Area"
|
||||
msgstr ""
|
||||
|
||||
msgid "Corridor"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trajectory"
|
||||
msgstr ""
|
||||
|
||||
msgid "Radius"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locations"
|
||||
msgstr ""
|
||||
|
||||
msgid "Instances"
|
||||
msgstr ""
|
||||
|
||||
@@ -18,6 +18,10 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.11.0\n"
|
||||
|
||||
#: pygeoapi/templates/_base.html:2
|
||||
msgid "text_direction"
|
||||
msgstr "ltr"
|
||||
|
||||
#: pygeoapi/templates/_base.html:51
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
@@ -521,3 +525,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
msgstr ""
|
||||
|
||||
msgid "Position"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cube"
|
||||
msgstr ""
|
||||
|
||||
msgid "Area"
|
||||
msgstr ""
|
||||
|
||||
msgid "Corridor"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trajectory"
|
||||
msgstr ""
|
||||
|
||||
msgid "Radius"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locations"
|
||||
msgstr ""
|
||||
|
||||
msgid "Instances"
|
||||
msgstr ""
|
||||
|
||||
@@ -18,6 +18,10 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.9.1\n"
|
||||
|
||||
#: pygeoapi/templates/_base.html:2
|
||||
msgid "text_direction"
|
||||
msgstr "ltr"
|
||||
|
||||
#: build/lib/pygeoapi/templates/_base.html:40
|
||||
#: build/lib/pygeoapi/templates/landing_page.html:2
|
||||
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
||||
@@ -715,3 +719,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
msgstr ""
|
||||
|
||||
msgid "Position"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cube"
|
||||
msgstr ""
|
||||
|
||||
msgid "Area"
|
||||
msgstr ""
|
||||
|
||||
msgid "Corridor"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trajectory"
|
||||
msgstr ""
|
||||
|
||||
msgid "Radius"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locations"
|
||||
msgstr ""
|
||||
|
||||
msgid "Instances"
|
||||
msgstr ""
|
||||
|
||||
@@ -19,6 +19,10 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.14.0\n"
|
||||
|
||||
#: pygeoapi/templates/_base.html:2
|
||||
msgid "text_direction"
|
||||
msgstr "ltr"
|
||||
|
||||
#: pygeoapi/templates/_base.html:51
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
@@ -656,3 +660,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
msgstr ""
|
||||
|
||||
msgid "Position"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cube"
|
||||
msgstr ""
|
||||
|
||||
msgid "Area"
|
||||
msgstr ""
|
||||
|
||||
msgid "Corridor"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trajectory"
|
||||
msgstr ""
|
||||
|
||||
msgid "Radius"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locations"
|
||||
msgstr ""
|
||||
|
||||
msgid "Instances"
|
||||
msgstr ""
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
#
|
||||
# =================================================================
|
||||
|
||||
__version__ = '0.18.dev0'
|
||||
__version__ = '0.19.dev0'
|
||||
|
||||
import click
|
||||
try:
|
||||
|
||||
@@ -75,11 +75,12 @@ LOGGER = logging.getLogger(__name__)
|
||||
#: Return headers for requests (e.g:X-Powered-By)
|
||||
HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
# 'X-Powered-By': f'pygeoapi {__version__}'
|
||||
'X-Powered-By': f'pygeoapi {__version__}'
|
||||
}
|
||||
|
||||
CHARSET = ['utf-8']
|
||||
F_JSON = 'json'
|
||||
F_COVERAGEJSON = 'json'
|
||||
F_HTML = 'html'
|
||||
F_JSONLD = 'jsonld'
|
||||
F_GZIP = 'gzip'
|
||||
@@ -1209,6 +1210,7 @@ class API:
|
||||
if edr:
|
||||
# TODO: translate
|
||||
LOGGER.debug('Adding EDR links')
|
||||
collection['data_queries'] = {}
|
||||
parameters = p.get_fields()
|
||||
if parameters:
|
||||
collection['parameter_names'] = {}
|
||||
@@ -1229,6 +1231,14 @@ class API:
|
||||
}
|
||||
|
||||
for qt in p.get_query_types():
|
||||
data_query = {
|
||||
'link': {
|
||||
'href': f'{self.get_collections_url()}/{k}/{qt}',
|
||||
'rel': 'data'
|
||||
}
|
||||
}
|
||||
collection['data_queries'][qt] = data_query
|
||||
|
||||
title1 = l10n.translate('query for this collection as JSON', request.locale) # noqa
|
||||
title1 = f'{qt} {title1}'
|
||||
title2 = l10n.translate('query for this collection as HTML', request.locale) # noqa
|
||||
@@ -1373,6 +1383,7 @@ class API:
|
||||
self.config['resources'][dataset]['title'], request.locale)
|
||||
|
||||
schema['collections_path'] = self.get_collections_url()
|
||||
schema['dataset_path'] = f'{self.get_collections_url()}/{dataset}'
|
||||
|
||||
content = render_j2_template(self.tpl_config,
|
||||
'collections/schema.html',
|
||||
@@ -1430,7 +1441,8 @@ class API:
|
||||
# Content-Language is in the system locale (ignore language settings)
|
||||
headers = request.get_response_headers(SYSTEM_LOCALE,
|
||||
**self.api_headers)
|
||||
msg = f'Invalid format: {request.format}'
|
||||
msg = 'Invalid format requested'
|
||||
LOGGER.error(f'{msg}: {request.format}')
|
||||
return self.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers,
|
||||
request.format, 'InvalidParameterValue', msg)
|
||||
|
||||
@@ -41,10 +41,12 @@
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Tuple
|
||||
import urllib
|
||||
|
||||
from shapely.errors import WKTReadingError
|
||||
from shapely.errors import ShapelyError
|
||||
from shapely.wkt import loads as shapely_loads
|
||||
|
||||
from pygeoapi import l10n
|
||||
from pygeoapi.plugin import load_plugin, PLUGINS
|
||||
from pygeoapi.provider.base import ProviderGenericError
|
||||
from pygeoapi.util import (
|
||||
@@ -52,7 +54,8 @@ from pygeoapi.util import (
|
||||
to_json, filter_dict_by_key_value
|
||||
)
|
||||
|
||||
from . import APIRequest, API, F_HTML, validate_datetime, validate_bbox
|
||||
from . import (APIRequest, API, F_COVERAGEJSON, F_HTML, F_JSONLD,
|
||||
validate_datetime, validate_bbox)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,6 +91,27 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
||||
return api.get_exception(
|
||||
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
|
||||
|
||||
LOGGER.debug('Loading provider')
|
||||
try:
|
||||
p = load_plugin('provider', get_provider_by_type(
|
||||
collections[dataset]['providers'], 'edr'))
|
||||
except ProviderGenericError as err:
|
||||
return api.get_exception(
|
||||
err.http_status_code, headers, request.format,
|
||||
err.ogc_exception_code, err.message)
|
||||
|
||||
if instance is not None and not p.get_instance(instance):
|
||||
msg = 'Invalid instance identifier'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers,
|
||||
request.format, 'InvalidParameterValue', msg)
|
||||
|
||||
if query_type not in p.get_query_types():
|
||||
msg = 'Unsupported query type'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
|
||||
LOGGER.debug('Processing query parameters')
|
||||
|
||||
LOGGER.debug('Processing datetime parameter')
|
||||
@@ -124,7 +148,7 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
||||
if wkt:
|
||||
try:
|
||||
wkt = shapely_loads(wkt)
|
||||
except WKTReadingError:
|
||||
except ShapelyError:
|
||||
msg = 'invalid coords parameter'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
@@ -144,27 +168,6 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
||||
LOGGER.debug('Processing z parameter')
|
||||
z = request.params.get('z')
|
||||
|
||||
LOGGER.debug('Loading provider')
|
||||
try:
|
||||
p = load_plugin('provider', get_provider_by_type(
|
||||
collections[dataset]['providers'], 'edr'))
|
||||
except ProviderGenericError as err:
|
||||
return api.get_exception(
|
||||
err.http_status_code, headers, request.format,
|
||||
err.ogc_exception_code, err.message)
|
||||
|
||||
if instance is not None and not p.get_instance(instance):
|
||||
msg = 'Invalid instance identifier'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers,
|
||||
request.format, 'InvalidParameterValue', msg)
|
||||
|
||||
if query_type not in p.get_query_types():
|
||||
msg = 'Unsupported query type'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
|
||||
if parameternames and not any((fld in parameternames)
|
||||
for fld in p.get_fields().keys()):
|
||||
msg = 'Invalid parameter-name'
|
||||
@@ -195,6 +198,36 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
||||
err.ogc_exception_code, err.message)
|
||||
|
||||
if request.format == F_HTML: # render
|
||||
uri = f'{api.get_collections_url()}/{dataset}/{query_type}'
|
||||
serialized_query_params = ''
|
||||
for k, v in request.params.items():
|
||||
if k != 'f':
|
||||
serialized_query_params += '&'
|
||||
serialized_query_params += urllib.parse.quote(k, safe='')
|
||||
serialized_query_params += '='
|
||||
serialized_query_params += urllib.parse.quote(str(v), safe=',')
|
||||
|
||||
data['query_type'] = query_type.capitalize()
|
||||
data['query_path'] = uri
|
||||
data['dataset_path'] = '/'.join(uri.split('/')[:-1])
|
||||
data['collections_path'] = api.get_collections_url()
|
||||
|
||||
data['links'] = [{
|
||||
'rel': 'collection',
|
||||
'title': collections[dataset]['title'],
|
||||
'href': data['dataset_path']
|
||||
}, {
|
||||
'type': 'application/prs.coverage+json',
|
||||
'rel': request.get_linkrel(F_COVERAGEJSON),
|
||||
'title': l10n.translate('This document as CoverageJSON', request.locale), # noqa
|
||||
'href': f'{uri}?f={F_COVERAGEJSON}{serialized_query_params}'
|
||||
}, {
|
||||
'type': 'application/ld+json',
|
||||
'rel': 'alternate',
|
||||
'title': l10n.translate('This document as JSON-LD', request.locale), # noqa
|
||||
'href': f'{uri}?f={F_JSONLD}{serialized_query_params}'
|
||||
}]
|
||||
|
||||
content = render_j2_template(api.tpl_config,
|
||||
'collections/edr/query.html', data,
|
||||
api.default_locale)
|
||||
@@ -305,11 +338,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str,
|
||||
'tags': [k],
|
||||
'operationId': f'queryLOCATIONSBYID{k.capitalize()}',
|
||||
'parameters': [
|
||||
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/{spatial_parameter}.yaml"}, # noqa
|
||||
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/locationId.yaml"}, # noqa
|
||||
{'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa
|
||||
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/parameter-name.yaml"}, # noqa
|
||||
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/z.yaml"}, # noqa
|
||||
{'$ref': '#/components/parameters/f'}
|
||||
],
|
||||
'responses': {
|
||||
|
||||
+44
-50
@@ -121,24 +121,22 @@ def get_collection_queryables(api: API, request: Union[APIRequest, Any],
|
||||
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
|
||||
|
||||
LOGGER.debug('Creating collection queryables')
|
||||
try:
|
||||
LOGGER.debug('Loading feature provider')
|
||||
p = load_plugin('provider', get_provider_by_type(
|
||||
api.config['resources'][dataset]['providers'], 'feature'))
|
||||
p._load()
|
||||
except ProviderTypeError:
|
||||
|
||||
p = None
|
||||
for pt in ['feature', 'coverage', 'record']:
|
||||
try:
|
||||
LOGGER.debug('Loading coverage provider')
|
||||
LOGGER.debug(f'Loading {pt} provider')
|
||||
p = load_plugin('provider', get_provider_by_type(
|
||||
api.config['resources'][dataset]['providers'], 'coverage')) # noqa
|
||||
api.config['resources'][dataset]['providers'], pt))
|
||||
break
|
||||
except ProviderTypeError:
|
||||
LOGGER.debug('Loading record provider')
|
||||
p = load_plugin('provider', get_provider_by_type(
|
||||
api.config['resources'][dataset]['providers'], 'record'))
|
||||
except ProviderGenericError as err:
|
||||
LOGGER.debug(f'Providing type {pt} not found')
|
||||
|
||||
if p is None:
|
||||
msg = 'queryables not available for this collection'
|
||||
return api.get_exception(
|
||||
err.http_status_code, headers, request.format,
|
||||
err.ogc_exception_code, err.message)
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'NoApplicableError', msg)
|
||||
|
||||
queryables = {
|
||||
'type': 'object',
|
||||
@@ -183,6 +181,7 @@ def get_collection_queryables(api: API, request: Union[APIRequest, Any],
|
||||
api.config['resources'][dataset]['title'], request.locale)
|
||||
|
||||
queryables['collections_path'] = api.get_collections_url()
|
||||
queryables['dataset_path'] = f'{api.get_collections_url()}/{dataset}'
|
||||
|
||||
content = render_j2_template(api.tpl_config,
|
||||
'collections/queryables.html',
|
||||
@@ -305,18 +304,7 @@ def get_collection_items(
|
||||
provider_type = 'feature'
|
||||
provider_def = get_provider_by_type(
|
||||
collections[dataset]['providers'], provider_type)
|
||||
|
||||
# clear data if no URL params
|
||||
load_data = False
|
||||
for item in request.params:
|
||||
if item.lower() == 'speckleurl' and len(request.params[item])>40 and ('speckleurl=' + request.params[item]) in provider_def['data'].lower():
|
||||
load_data = True
|
||||
break
|
||||
if load_data is False:
|
||||
provider_def['data'] = ""
|
||||
|
||||
p = load_plugin('provider', provider_def)
|
||||
|
||||
except ProviderTypeError:
|
||||
try:
|
||||
provider_type = 'record'
|
||||
@@ -392,8 +380,12 @@ def get_collection_items(
|
||||
|
||||
LOGGER.debug('processing property parameters')
|
||||
for k, v in request.params.items():
|
||||
if k not in reserved_fieldnames and k in list(p.fields.keys()):
|
||||
LOGGER.debug(f'Adding property filter {k}={v}')
|
||||
if k not in reserved_fieldnames:
|
||||
if k in list(p.fields.keys()):
|
||||
LOGGER.debug(f'Adding property filter {k}={v}')
|
||||
else:
|
||||
LOGGER.debug(f'Adding additional property filter {k}={v}')
|
||||
|
||||
properties.append((k, v))
|
||||
|
||||
LOGGER.debug('processing sort parameter')
|
||||
@@ -456,7 +448,8 @@ def get_collection_items(
|
||||
geometry_column_name=provider_def.get('geom_field'),
|
||||
)
|
||||
except Exception:
|
||||
msg = f'Bad CQL string : {cql_text}'
|
||||
msg = 'Bad CQL text'
|
||||
LOGGER.error(f'{msg}: {cql_text}')
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
@@ -543,17 +536,23 @@ def get_collection_items(
|
||||
'href': f'{uri}?offset={prev}{serialized_query_params}'
|
||||
})
|
||||
|
||||
if 'numberMatched' in content:
|
||||
if content['numberMatched'] > (limit + offset):
|
||||
next_ = offset + limit
|
||||
next_href = f'{uri}?offset={next_}{serialized_query_params}'
|
||||
content['links'].append(
|
||||
{
|
||||
'type': 'application/geo+json',
|
||||
'rel': 'next',
|
||||
'title': l10n.translate('Items (next)', request.locale),
|
||||
'href': next_href
|
||||
})
|
||||
next_link = False
|
||||
|
||||
if content.get('numberMatched', -1) > (limit + offset):
|
||||
next_link = True
|
||||
elif len(content['features']) == limit:
|
||||
next_link = True
|
||||
|
||||
if next_link:
|
||||
next_ = offset + limit
|
||||
next_href = f'{uri}?offset={next_}{serialized_query_params}'
|
||||
content['links'].append(
|
||||
{
|
||||
'type': 'application/geo+json',
|
||||
'rel': 'next',
|
||||
'title': l10n.translate('Items (next)', request.locale),
|
||||
'href': next_href
|
||||
})
|
||||
|
||||
content['links'].append(
|
||||
{
|
||||
@@ -566,14 +565,7 @@ def get_collection_items(
|
||||
|
||||
content['timeStamp'] = datetime.utcnow().strftime(
|
||||
'%Y-%m-%dT%H:%M:%S.%fZ')
|
||||
|
||||
# Save passed parameters
|
||||
url_saved_as_data = collections[dataset]['providers'][0]['data']
|
||||
url_props = []
|
||||
|
||||
if isinstance(url_saved_as_data, str):
|
||||
url_props = url_saved_as_data.lower().split("&")
|
||||
|
||||
# Set response language to requested provider locale
|
||||
# (if it supports language) and/or otherwise the requested pygeoapi
|
||||
# locale (or fallback default locale)
|
||||
@@ -855,7 +847,7 @@ def post_collection_items(
|
||||
if (request_headers.get(
|
||||
'Content-Type') or request_headers.get(
|
||||
'content-type')) != 'application/query-cql-json':
|
||||
msg = ('Invalid body content-type')
|
||||
msg = 'Invalid body content-type'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidHeaderValue', msg)
|
||||
@@ -891,16 +883,18 @@ def post_collection_items(
|
||||
geometry_column_name=provider_def.get('geom_field')
|
||||
)
|
||||
except Exception:
|
||||
msg = f'Bad CQL string : {data}'
|
||||
msg = 'Bad CQL text'
|
||||
LOGGER.error(f'{msg}: {data}')
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
else:
|
||||
LOGGER.debug('processing Elasticsearch CQL_JSON data')
|
||||
LOGGER.debug('processing CQL_JSON data')
|
||||
try:
|
||||
filter_ = CQLModel.parse_raw(data)
|
||||
except Exception:
|
||||
msg = f'Bad CQL string : {data}'
|
||||
msg = 'Bad CQL text'
|
||||
LOGGER.error(f'{msg}: {data}')
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
|
||||
@@ -46,6 +46,7 @@ from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
from typing import Tuple
|
||||
import urllib.parse
|
||||
|
||||
from pygeoapi import l10n
|
||||
from pygeoapi.util import (
|
||||
@@ -240,10 +241,51 @@ def get_jobs(api: API, request: APIRequest,
|
||||
|
||||
headers = request.get_response_headers(SYSTEM_LOCALE,
|
||||
**api.api_headers)
|
||||
LOGGER.debug('Processing limit parameter')
|
||||
try:
|
||||
limit = int(request.params.get('limit'))
|
||||
|
||||
if limit <= 0:
|
||||
msg = 'limit value should be strictly positive'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
except TypeError:
|
||||
limit = int(api.config['server']['limit'])
|
||||
LOGGER.debug('returning all jobs')
|
||||
except ValueError:
|
||||
msg = 'limit value should be an integer'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
|
||||
LOGGER.debug('Processing offset parameter')
|
||||
try:
|
||||
offset = int(request.params.get('offset'))
|
||||
if offset < 0:
|
||||
msg = 'offset value should be positive or zero'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
except TypeError as err:
|
||||
LOGGER.warning(err)
|
||||
offset = 0
|
||||
except ValueError:
|
||||
msg = 'offset value should be an integer'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
|
||||
if job_id is None:
|
||||
jobs = sorted(api.manager.get_jobs(),
|
||||
jobs_data = api.manager.get_jobs(limit=limit, offset=offset)
|
||||
# TODO: For pagination to work, the provider has to do the sorting.
|
||||
# Here we do sort again in case the provider doesn't support
|
||||
# pagination yet and always returns all jobs.
|
||||
jobs = sorted(jobs_data['jobs'],
|
||||
key=lambda k: k['job_start_datetime'],
|
||||
reverse=True)
|
||||
numberMatched = jobs_data['numberMatched']
|
||||
|
||||
else:
|
||||
try:
|
||||
jobs = [api.manager.get_job(job_id)]
|
||||
@@ -251,6 +293,7 @@ def get_jobs(api: API, request: APIRequest,
|
||||
return api.get_exception(
|
||||
HTTPStatus.NOT_FOUND, headers, request.format,
|
||||
'InvalidParameterValue', job_id)
|
||||
numberMatched = 1
|
||||
|
||||
serialized_jobs = {
|
||||
'jobs': [],
|
||||
@@ -309,6 +352,44 @@ def get_jobs(api: API, request: APIRequest,
|
||||
|
||||
serialized_jobs['jobs'].append(job2)
|
||||
|
||||
serialized_query_params = ''
|
||||
for k, v in request.params.items():
|
||||
if k not in ('f', 'offset'):
|
||||
serialized_query_params += '&'
|
||||
serialized_query_params += urllib.parse.quote(k, safe='')
|
||||
serialized_query_params += '='
|
||||
serialized_query_params += urllib.parse.quote(str(v), safe=',')
|
||||
|
||||
uri = f'{api.base_url}/jobs'
|
||||
|
||||
if offset > 0:
|
||||
prev = max(0, offset - limit)
|
||||
serialized_jobs['links'].append(
|
||||
{
|
||||
'href': f'{uri}?offset={prev}{serialized_query_params}',
|
||||
'type': FORMAT_TYPES[F_JSON],
|
||||
'rel': 'prev',
|
||||
'title': l10n.translate('Items (prev)', request.locale),
|
||||
})
|
||||
|
||||
next_link = False
|
||||
|
||||
if numberMatched > (limit + offset):
|
||||
next_link = True
|
||||
elif len(jobs) == limit:
|
||||
next_link = True
|
||||
|
||||
if next_link:
|
||||
next_ = offset + limit
|
||||
next_href = f'{uri}?offset={next_}{serialized_query_params}'
|
||||
serialized_jobs['links'].append(
|
||||
{
|
||||
'href': next_href,
|
||||
'rel': 'next',
|
||||
'type': FORMAT_TYPES[F_JSON],
|
||||
'title': l10n.translate('Items (next)', request.locale),
|
||||
})
|
||||
|
||||
if job_id is None:
|
||||
j2_template = 'jobs/index.html'
|
||||
else:
|
||||
@@ -318,6 +399,7 @@ def get_jobs(api: API, request: APIRequest,
|
||||
if request.format == F_HTML:
|
||||
data = {
|
||||
'jobs': serialized_jobs,
|
||||
'offset': offset,
|
||||
'now': datetime.now(timezone.utc).strftime(DATETIME_FORMAT)
|
||||
}
|
||||
response = render_j2_template(api.tpl_config, j2_template, data,
|
||||
@@ -379,6 +461,8 @@ def execute_process(api: API, request: APIRequest,
|
||||
requested_outputs = data.get('outputs')
|
||||
LOGGER.debug(f'outputs: {requested_outputs}')
|
||||
|
||||
requested_response = data.get('response', 'raw')
|
||||
|
||||
subscriber = None
|
||||
subscriber_dict = data.get('subscriber')
|
||||
if subscriber_dict:
|
||||
@@ -407,10 +491,14 @@ def execute_process(api: API, request: APIRequest,
|
||||
result = api.manager.execute_process(
|
||||
process_id, data_dict, execution_mode=execution_mode,
|
||||
requested_outputs=requested_outputs,
|
||||
subscriber=subscriber)
|
||||
subscriber=subscriber,
|
||||
requested_response=requested_response)
|
||||
job_id, mime_type, outputs, status, additional_headers = result
|
||||
headers.update(additional_headers or {})
|
||||
headers['Location'] = f'{api.base_url}/jobs/{job_id}'
|
||||
|
||||
if api.manager.is_async:
|
||||
headers['Location'] = f'{api.base_url}/jobs/{job_id}'
|
||||
|
||||
except ProcessorExecuteError as err:
|
||||
return api.get_exception(
|
||||
err.http_status_code, headers,
|
||||
@@ -420,11 +508,11 @@ def execute_process(api: API, request: APIRequest,
|
||||
if status == JobStatus.failed:
|
||||
response = outputs
|
||||
|
||||
if data.get('response', 'raw') == 'raw':
|
||||
if requested_response == 'raw':
|
||||
headers['Content-Type'] = mime_type
|
||||
response = outputs
|
||||
elif status not in (JobStatus.failed, JobStatus.accepted):
|
||||
response['outputs'] = [outputs]
|
||||
response = outputs
|
||||
|
||||
if status == JobStatus.accepted:
|
||||
http_status = HTTPStatus.CREATED
|
||||
@@ -433,7 +521,7 @@ def execute_process(api: API, request: APIRequest,
|
||||
else:
|
||||
http_status = HTTPStatus.OK
|
||||
|
||||
if mime_type == 'application/json':
|
||||
if mime_type == 'application/json' or requested_response == 'document':
|
||||
response2 = to_json(response, api.pretty_print)
|
||||
else:
|
||||
response2 = response
|
||||
|
||||
+12
-65
@@ -29,7 +29,6 @@
|
||||
#
|
||||
# =================================================================
|
||||
|
||||
import copy
|
||||
import click
|
||||
import json
|
||||
from jsonschema import validate as jsonschema_validate
|
||||
@@ -37,15 +36,12 @@ import logging
|
||||
import os
|
||||
import yaml
|
||||
|
||||
from flask import Request
|
||||
|
||||
from pygeoapi.util import to_json, yaml_load, THISDIR
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
CONFIG = {}
|
||||
|
||||
|
||||
def get_config(raw: bool = False, request: Request = None) -> dict:
|
||||
def get_config(raw: bool = False) -> dict:
|
||||
"""
|
||||
Get pygeoapi configurations
|
||||
|
||||
@@ -54,71 +50,22 @@ def get_config(raw: bool = False, request: Request = None) -> dict:
|
||||
:returns: `dict` of pygeoapi configuration
|
||||
"""
|
||||
|
||||
if not os.environ.get("PYGEOAPI_CONFIG"):
|
||||
raise RuntimeError("PYGEOAPI_CONFIG environment variable not set")
|
||||
|
||||
map_api_key_local = os.environ.get("MAPTILER_KEY_LOCAL")
|
||||
map_api_key_speckle = os.environ.get("MAPTILER_KEY_SPECKLE")
|
||||
|
||||
global CONFIG
|
||||
if not os.environ.get('PYGEOAPI_CONFIG'):
|
||||
raise RuntimeError('PYGEOAPI_CONFIG environment variable not set')
|
||||
|
||||
config_file = os.environ.get("PYGEOAPI_CONFIG")
|
||||
with open(config_file, encoding="utf8") as fh:
|
||||
with open(os.environ.get('PYGEOAPI_CONFIG'), encoding='utf8') as fh:
|
||||
if raw:
|
||||
config_yaml = yaml.safe_load(fh)
|
||||
CONFIG = yaml.safe_load(fh)
|
||||
else:
|
||||
config_yaml = yaml_load(fh)
|
||||
|
||||
# assign valid dictionnaries to Speckle resources
|
||||
speckle_collection_received = copy.deepcopy(config_yaml["resources"]["speckle"])
|
||||
|
||||
# for the first time only: assign YAML value to CONFIG. Otherwise, don't modify
|
||||
if CONFIG == {}:
|
||||
CONFIG = config_yaml
|
||||
|
||||
url_valid = False
|
||||
speckle_url = ""
|
||||
if request is not None:
|
||||
url = request.url.split("?")[-1]
|
||||
if "projects" in url and "models" in url:
|
||||
url_valid = True
|
||||
speckle_url = url
|
||||
|
||||
# if a key found, replace basemap URL to MapTiler
|
||||
# make sure to restrict the usage for the key
|
||||
if ".speckle.systems" in request.url.split("?")[0] and map_api_key_speckle and len(map_api_key_speckle)>=20:
|
||||
CONFIG["server"]["map"]["url"] = r'https://api.maptiler.com/maps/dataviz/{z}/{x}/{y}.png' + f'?key={map_api_key_speckle}'
|
||||
CONFIG["server"]["map"]["key"] = f'{map_api_key_speckle}'
|
||||
CONFIG["server"]["map"]["attribution"] = r'<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap contributors</a>'
|
||||
|
||||
elif map_api_key_local and len(map_api_key_local)>=20:
|
||||
CONFIG["server"]["map"]["url"] = r'https://api.maptiler.com/maps/dataviz/{z}/{x}/{y}.png' + f'?key={map_api_key_local}'
|
||||
CONFIG["server"]["map"]["key"] = f'{map_api_key_local}'
|
||||
CONFIG["server"]["map"]["attribution"] = r'<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap contributors</a>'
|
||||
else:
|
||||
CONFIG["server"]["map"]["url"] = r'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
CONFIG["server"]["map"]["key"] = ""
|
||||
CONFIG["server"]["map"]["attribution"] = r'© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
|
||||
|
||||
# once Speckle URL is found, set it as a provider
|
||||
if url_valid:
|
||||
# speckle_collection_pts["title"]["en"] = "Some Points"
|
||||
|
||||
# assign speckle url and get the data
|
||||
speckle_collection_received["providers"][0]["data"] = speckle_url
|
||||
|
||||
CONFIG["resources"] = {
|
||||
"speckle": speckle_collection_received,
|
||||
}
|
||||
CONFIG = yaml_load(fh)
|
||||
|
||||
return CONFIG
|
||||
|
||||
|
||||
def load_schema() -> dict:
|
||||
"""Reads the JSON schema YAML file."""
|
||||
""" Reads the JSON schema YAML file. """
|
||||
|
||||
schema_file = THISDIR / "schemas" / "config" / "pygeoapi-config-0.x.yml"
|
||||
schema_file = THISDIR / 'schemas' / 'config' / 'pygeoapi-config-0.x.yml'
|
||||
|
||||
with schema_file.open() as fh2:
|
||||
return yaml_load(fh2)
|
||||
@@ -146,18 +93,18 @@ def config():
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
@click.option("--config", "-c", "config_file", help="configuration file")
|
||||
@click.option('--config', '-c', 'config_file', help='configuration file')
|
||||
def validate(ctx, config_file):
|
||||
"""Validate configuration"""
|
||||
|
||||
if config_file is None:
|
||||
raise click.ClickException("--config/-c required")
|
||||
raise click.ClickException('--config/-c required')
|
||||
|
||||
with open(config_file) as ff:
|
||||
click.echo(f"Validating {config_file}")
|
||||
click.echo(f'Validating {config_file}')
|
||||
instance = yaml_load(ff)
|
||||
validate_config(instance)
|
||||
click.echo("Valid configuration")
|
||||
click.echo('Valid configuration')
|
||||
|
||||
|
||||
config.add_command(validate)
|
||||
|
||||
+12
-150
@@ -34,13 +34,8 @@ import os
|
||||
from typing import Union
|
||||
|
||||
import click
|
||||
from datetime import datetime, timezone
|
||||
from flask import (Flask, Blueprint, make_response, request,
|
||||
send_from_directory, Response, Request, stream_with_context)
|
||||
from http import HTTPStatus
|
||||
|
||||
import json
|
||||
from urllib.request import urlopen
|
||||
send_from_directory, Response, Request)
|
||||
|
||||
from pygeoapi.api import API, APIRequest, apply_gzip
|
||||
import pygeoapi.api.coverages as coverages_api
|
||||
@@ -50,10 +45,9 @@ import pygeoapi.api.maps as maps_api
|
||||
import pygeoapi.api.processes as processes_api
|
||||
import pygeoapi.api.stac as stac_api
|
||||
import pygeoapi.api.tiles as tiles_api
|
||||
from pygeoapi.provider.speckle_utils.legal import COUNTRY_CODES
|
||||
from pygeoapi.openapi import load_openapi_document
|
||||
from pygeoapi.config import get_config
|
||||
from pygeoapi.util import get_mimetype, get_api_rules, render_j2_template
|
||||
from pygeoapi.util import get_mimetype, get_api_rules
|
||||
|
||||
|
||||
CONFIG = get_config()
|
||||
@@ -83,7 +77,7 @@ ADMIN_BLUEPRINT = Blueprint('admin', __name__, static_folder=STATIC_FOLDER)
|
||||
if CONFIG['server'].get('cors', False):
|
||||
try:
|
||||
from flask_cors import CORS
|
||||
CORS(APP)
|
||||
CORS(APP, CORS_EXPOSE_HEADERS=['*'])
|
||||
except ModuleNotFoundError:
|
||||
print('Python package flask-cors required for CORS support')
|
||||
|
||||
@@ -156,9 +150,6 @@ def execute_from_flask(api_function, request: Request, *args,
|
||||
:returns: A Response instance
|
||||
"""
|
||||
|
||||
CONFIG = get_config(request=request)
|
||||
api_ = API(CONFIG, OPENAPI)
|
||||
|
||||
api_request = APIRequest.from_flask(request, api_.locales)
|
||||
|
||||
content: Union[str, bytes]
|
||||
@@ -173,62 +164,6 @@ def execute_from_flask(api_function, request: Request, *args,
|
||||
return get_response((headers, status, content))
|
||||
|
||||
|
||||
def handle_client(url_route: str):
|
||||
|
||||
# if called fromm the browser, Exceptions from this function will result in infinite load
|
||||
agent = request.headers.get('User-Agent')
|
||||
if request.environ.get('HTTP_X_FORWARDED_FOR') is None:
|
||||
ip_address = request.environ['REMOTE_ADDR']
|
||||
else:
|
||||
ip_address = request.environ['HTTP_X_FORWARDED_FOR']
|
||||
|
||||
if agent is not None and "(https://www.checklyhq.com)" not in agent:
|
||||
print(f"_______________________{datetime.now().astimezone(timezone.utc)} _URL access")
|
||||
print(f"_Agent {url_route}: {agent}")
|
||||
print(f"_IP Address: {ip_address}")
|
||||
print(f"_Request URL: {request.url}")
|
||||
|
||||
request.url += f"&userAgent={agent}"
|
||||
|
||||
# by Agent:
|
||||
if agent is not None and ("YaBrowser/" in agent or "yandex" in agent.lower()):
|
||||
raise ValueError("Your browser is not supported.")
|
||||
|
||||
# by IP:
|
||||
try:
|
||||
url = 'https://ipinfo.io/' + ip_address + '/json'
|
||||
res = urlopen(url)
|
||||
data = json.load(res)
|
||||
if isinstance(data, dict) and isinstance(data["country"], str):
|
||||
if data["country"].lower() in COUNTRY_CODES:
|
||||
raise PermissionError("Review Speckle Terms and Conditions")
|
||||
else:
|
||||
print(f"Error validating client: DATA {data}")
|
||||
except Exception as e:
|
||||
print(f"Error validating client from start: {e}")
|
||||
|
||||
def generate():
|
||||
collection_id = "speckle"
|
||||
|
||||
yield loading_screen().data
|
||||
|
||||
handle_client("/")
|
||||
CONFIG = get_config(request=request)
|
||||
api_ = API(CONFIG, OPENAPI)
|
||||
|
||||
try:
|
||||
browser_response = execute_from_flask(itemtypes_api.get_collection_items,
|
||||
request, collection_id,
|
||||
skip_valid_check=True)
|
||||
yield browser_response.data
|
||||
|
||||
except PermissionError as ex:
|
||||
raise ex
|
||||
|
||||
except Exception as ex:
|
||||
yield error_screen(ex).data
|
||||
|
||||
|
||||
@BLUEPRINT.route('/')
|
||||
def landing_page():
|
||||
"""
|
||||
@@ -236,45 +171,8 @@ def landing_page():
|
||||
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
agent = request.headers.get('User-Agent')
|
||||
browser_agent = False
|
||||
browser_list = ["Chrome", "Safari", "Firefox", "Edg/", "Trident/"]
|
||||
for br in browser_list:
|
||||
if agent is not None and br in agent:
|
||||
browser_agent = True
|
||||
break
|
||||
|
||||
|
||||
# if requested from the browser, return this, otherwise ignore IF statement
|
||||
if request.method == 'GET' and browser_agent: # list items
|
||||
return Response(stream_with_context(generate()))
|
||||
|
||||
# for non-browsers
|
||||
handle_client("/")
|
||||
CONFIG = get_config(request=request)
|
||||
api_ = API(CONFIG, OPENAPI)
|
||||
return get_response(api_.landing_page(request))
|
||||
|
||||
def error_screen(ex: Exception):
|
||||
"""
|
||||
Loading empty page
|
||||
|
||||
:returns: HTTP response
|
||||
"""
|
||||
content = render_j2_template(api_.tpl_config, 'error_screen.html',{"exception": ex})
|
||||
|
||||
return get_response((request.headers, HTTPStatus.OK, content))
|
||||
|
||||
def loading_screen():
|
||||
"""
|
||||
Loading empty page
|
||||
|
||||
:returns: HTTP response
|
||||
"""
|
||||
content = render_j2_template(api_.tpl_config, 'loading_screen.html',{'url': CONFIG["server"]["url"]})
|
||||
|
||||
return get_response((request.headers, HTTPStatus.OK, content))
|
||||
|
||||
@BLUEPRINT.route('/openapi')
|
||||
def openapi():
|
||||
@@ -283,8 +181,7 @@ def openapi():
|
||||
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
# raise NotImplementedError()
|
||||
|
||||
return get_response(api_.openapi_(request))
|
||||
|
||||
|
||||
@@ -295,8 +192,7 @@ def conformance():
|
||||
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
# raise NotImplementedError()
|
||||
|
||||
return get_response(api_.conformance(request))
|
||||
|
||||
|
||||
@@ -310,7 +206,6 @@ def get_tilematrix_set(tileMatrixSetId=None):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(tiles_api.tilematrixset, request,
|
||||
tileMatrixSetId)
|
||||
|
||||
@@ -323,7 +218,6 @@ def get_tilematrix_sets():
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(tiles_api.tilematrixsets, request)
|
||||
|
||||
|
||||
@@ -337,21 +231,10 @@ def collections(collection_id=None):
|
||||
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
handle_client("/collections")
|
||||
|
||||
return get_response(api_.describe_collections(request, collection_id))
|
||||
|
||||
|
||||
@BLUEPRINT.route('/speckle')
|
||||
def speckle_collection():
|
||||
|
||||
handle_client("/speckle")
|
||||
|
||||
collection_id="speckle"
|
||||
|
||||
return collection_items(collection_id=collection_id)
|
||||
|
||||
|
||||
@BLUEPRINT.route('/collections/<path:collection_id>/schema')
|
||||
def collection_schema(collection_id):
|
||||
"""
|
||||
@@ -362,7 +245,6 @@ def collection_schema(collection_id):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
# raise NotImplementedError()
|
||||
return get_response(api_.get_collection_schema(request, collection_id))
|
||||
|
||||
|
||||
@@ -376,12 +258,10 @@ def collection_queryables(collection_id=None):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
# raise NotImplementedError()
|
||||
return execute_from_flask(itemtypes_api.get_collection_queryables, request,
|
||||
collection_id)
|
||||
|
||||
|
||||
# @BLUEPRINT.route('/')
|
||||
@BLUEPRINT.route('/collections/<path:collection_id>/items',
|
||||
methods=['GET', 'POST', 'OPTIONS'],
|
||||
provide_automatic_options=False)
|
||||
@@ -397,17 +277,9 @@ def collection_items(collection_id, item_id=None):
|
||||
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
handle_client(f"/collections/{collection_id}/items")
|
||||
|
||||
collection_id = 'speckle'
|
||||
|
||||
if item_id is None:
|
||||
if request.method == 'GET': # list items
|
||||
return execute_from_flask(itemtypes_api.get_collection_items,
|
||||
request, collection_id,
|
||||
skip_valid_check=True)
|
||||
elif request.method == 'POST': # filter or manage items
|
||||
if request.method == 'POST': # filter or manage items
|
||||
if request.content_type is not None:
|
||||
if request.content_type == 'application/geo+json':
|
||||
return execute_from_flask(
|
||||
@@ -422,6 +294,10 @@ def collection_items(collection_id, item_id=None):
|
||||
return execute_from_flask(
|
||||
itemtypes_api.manage_collection_item, request, 'options',
|
||||
collection_id, skip_valid_check=True)
|
||||
else: # GET: list items
|
||||
return execute_from_flask(itemtypes_api.get_collection_items,
|
||||
request, collection_id,
|
||||
skip_valid_check=True)
|
||||
|
||||
elif request.method == 'DELETE':
|
||||
return execute_from_flask(itemtypes_api.manage_collection_item,
|
||||
@@ -449,7 +325,7 @@ def collection_coverage(collection_id):
|
||||
|
||||
:returns: HTTP response
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
return execute_from_flask(coverages_api.get_collection_coverage, request,
|
||||
collection_id, skip_valid_check=True)
|
||||
|
||||
@@ -464,7 +340,6 @@ def get_collection_tiles(collection_id=None):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(tiles_api.get_collection_tiles, request,
|
||||
collection_id)
|
||||
|
||||
@@ -481,7 +356,6 @@ def get_collection_tiles_metadata(collection_id=None, tileMatrixSetId=None):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(tiles_api.get_collection_tiles_metadata,
|
||||
request, collection_id, tileMatrixSetId,
|
||||
skip_valid_check=True)
|
||||
@@ -503,7 +377,6 @@ def get_collection_tiles_data(collection_id=None, tileMatrixSetId=None,
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(
|
||||
tiles_api.get_collection_tiles_data,
|
||||
request, collection_id, tileMatrixSetId, tileMatrix, tileRow, tileCol,
|
||||
@@ -523,7 +396,6 @@ def collection_map(collection_id, style_id=None):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(
|
||||
maps_api.get_collection_map, request, collection_id, style_id
|
||||
)
|
||||
@@ -540,7 +412,6 @@ def get_processes(process_id=None):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(processes_api.describe_processes, request,
|
||||
process_id)
|
||||
|
||||
@@ -557,7 +428,6 @@ def get_jobs(job_id=None):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
if job_id is None:
|
||||
return execute_from_flask(processes_api.get_jobs, request)
|
||||
else:
|
||||
@@ -578,7 +448,6 @@ def execute_process_jobs(process_id):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(processes_api.execute_process, request,
|
||||
process_id)
|
||||
|
||||
@@ -594,7 +463,6 @@ def get_job_result(job_id=None):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(processes_api.get_job_result, request, job_id)
|
||||
|
||||
|
||||
@@ -610,7 +478,6 @@ def get_job_result_resource(job_id, resource):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
# TODO: this does not seem to exist?
|
||||
return get_response(api_.get_job_result_resource(
|
||||
request, job_id, resource))
|
||||
@@ -664,7 +531,6 @@ def stac_catalog_root():
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(stac_api.get_stac_root, request)
|
||||
|
||||
|
||||
@@ -678,7 +544,6 @@ def stac_catalog_path(path):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return execute_from_flask(stac_api.get_stac_path, request, path)
|
||||
|
||||
|
||||
@@ -690,7 +555,6 @@ def admin_config():
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
if request.method == 'GET':
|
||||
return get_response(admin_.get_config(request))
|
||||
|
||||
@@ -709,7 +573,6 @@ def admin_config_resources():
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
if request.method == 'GET':
|
||||
return get_response(admin_.get_resources(request))
|
||||
|
||||
@@ -727,7 +590,6 @@ def admin_config_resource(resource_id):
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
if request.method == 'GET':
|
||||
return get_response(admin_.get_resource(request, resource_id))
|
||||
|
||||
|
||||
@@ -27,11 +27,10 @@
|
||||
#
|
||||
# =================================================================
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
|
||||
import unicodecsv as csv
|
||||
|
||||
from pygeoapi.formatter.base import BaseFormatter, FormatterSerializationError
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -83,10 +82,11 @@ class CSVFormatter(BaseFormatter):
|
||||
# TODO: implement wkt geometry serialization
|
||||
LOGGER.debug('not a point geometry, skipping')
|
||||
|
||||
print("JJJ", fields)
|
||||
LOGGER.debug(f'CSV fields: {fields}')
|
||||
|
||||
try:
|
||||
output = io.BytesIO()
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fields)
|
||||
writer.writeheader()
|
||||
|
||||
@@ -101,7 +101,7 @@ class CSVFormatter(BaseFormatter):
|
||||
LOGGER.error(err)
|
||||
raise FormatterSerializationError('Error writing CSV output')
|
||||
|
||||
return output.getvalue()
|
||||
return output.getvalue().encode('utf-8')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CSVFormatter> {self.name}'
|
||||
|
||||
@@ -410,7 +410,6 @@ def set_response_language(headers: dict, *locale_: Locale):
|
||||
|
||||
LOGGER.debug(f'Setting Content-Language to {loc_str}')
|
||||
headers['Content-Language'] = loc_str
|
||||
headers['Access-Control-Allow-Origin'] = "*"
|
||||
|
||||
|
||||
def add_locale(url, locale_) -> str:
|
||||
|
||||
+58
-5
@@ -134,6 +134,52 @@ def gen_response_object(description: str, media_type: str,
|
||||
return response
|
||||
|
||||
|
||||
def gen_contact(cfg: dict) -> dict:
|
||||
"""
|
||||
Generates an OpenAPI contact object with OGC extensions
|
||||
based on OGC API - Records contact
|
||||
|
||||
:param cfg: `dict` of configuration
|
||||
|
||||
:returns: `dict` of OpenAPI contact object
|
||||
"""
|
||||
|
||||
contact = {
|
||||
'name': cfg['metadata']['provider']['name'],
|
||||
'url': cfg['metadata']['provider']['url'],
|
||||
'email': cfg['metadata']['contact']['email']
|
||||
}
|
||||
|
||||
contact['x-ogc-serviceContact'] = {
|
||||
'name': cfg['metadata']['contact']['name'],
|
||||
'position': cfg['metadata']['contact']['position'],
|
||||
'addresses': [{
|
||||
'deliveryPoint': [cfg['metadata']['contact']['address']],
|
||||
'city': cfg['metadata']['contact']['city'],
|
||||
'administrativeArea': cfg['metadata']['contact']['stateorprovince'], # noqa
|
||||
'postalCode': cfg['metadata']['contact']['postalcode'],
|
||||
'country': cfg['metadata']['contact']['country']
|
||||
}],
|
||||
'phones': [{
|
||||
'type': 'main', 'value': cfg['metadata']['contact']['phone']
|
||||
}, {
|
||||
'type': 'fax', 'value': cfg['metadata']['contact']['fax']
|
||||
}],
|
||||
'emails': [{
|
||||
'value': cfg['metadata']['contact']['email']
|
||||
}],
|
||||
'contactInstructions': cfg['metadata']['contact']['instructions'],
|
||||
'links': [{
|
||||
'type': 'text/html',
|
||||
'href': cfg['metadata']['contact']['url']
|
||||
}],
|
||||
'hoursOfService': cfg['metadata']['contact']['hours'],
|
||||
'roles': [cfg['metadata']['contact']['role']]
|
||||
}
|
||||
|
||||
return contact
|
||||
|
||||
|
||||
def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict:
|
||||
"""
|
||||
Generates an OpenAPI 3.0 Document
|
||||
@@ -167,11 +213,7 @@ def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict:
|
||||
'x-keywords': l10n.translate(cfg['metadata']['identification']['keywords'], locale_), # noqa
|
||||
'termsOfService':
|
||||
cfg['metadata']['identification']['terms_of_service'],
|
||||
'contact': {
|
||||
'name': cfg['metadata']['provider']['name'],
|
||||
'url': cfg['metadata']['provider']['url'],
|
||||
'email': cfg['metadata']['contact']['email']
|
||||
},
|
||||
'contact': gen_contact(cfg),
|
||||
'license': {
|
||||
'name': cfg['metadata']['license']['name'],
|
||||
'url': cfg['metadata']['license']['url']
|
||||
@@ -903,6 +945,17 @@ def load_openapi_document() -> dict:
|
||||
|
||||
pygeoapi_openapi = os.environ.get('PYGEOAPI_OPENAPI')
|
||||
|
||||
if pygeoapi_openapi is None:
|
||||
msg = 'PYGEOAPI_OPENAPI environment not set'
|
||||
LOGGER.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if not os.path.exists(pygeoapi_openapi):
|
||||
msg = (f'OpenAPI document {pygeoapi_openapi} does not exist. '
|
||||
'Please generate before starting pygeoapi')
|
||||
LOGGER.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
with open(pygeoapi_openapi, encoding='utf8') as ff:
|
||||
if pygeoapi_openapi.endswith(('.yaml', '.yml')):
|
||||
openapi_ = yaml_load(ff)
|
||||
|
||||
+4
-3
@@ -51,16 +51,17 @@ PLUGINS = {
|
||||
'MapScript': 'pygeoapi.provider.mapscript_.MapScriptProvider',
|
||||
'MongoDB': 'pygeoapi.provider.mongo.MongoProvider',
|
||||
'MVT-tippecanoe': 'pygeoapi.provider.mvt_tippecanoe.MVTTippecanoeProvider', # noqa: E501
|
||||
'MVT-elastic': 'pygeoapi.provider.mvt_elastic.MVTElasticProvider', # noqa: E501
|
||||
'MVT-proxy': 'pygeoapi.provider.mvt_proxy.MVTProxyProvider', # noqa: E501
|
||||
'MVT-elastic': 'pygeoapi.provider.mvt_elastic.MVTElasticProvider',
|
||||
'MVT-proxy': 'pygeoapi.provider.mvt_proxy.MVTProxyProvider',
|
||||
'OracleDB': 'pygeoapi.provider.oracle.OracleProvider',
|
||||
'OGR': 'pygeoapi.provider.ogr.OGRProvider',
|
||||
'OpenSearch': 'pygeoapi.provider.opensearch_.OpenSearchProvider',
|
||||
'Parquet': 'pygeoapi.provider.parquet.ParquetProvider',
|
||||
'PostgreSQL': 'pygeoapi.provider.postgresql.PostgreSQLProvider',
|
||||
'rasterio': 'pygeoapi.provider.rasterio_.RasterioProvider',
|
||||
'SensorThings': 'pygeoapi.provider.sensorthings.SensorThingsProvider',
|
||||
'SQLiteGPKG': 'pygeoapi.provider.sqlite.SQLiteGPKGProvider',
|
||||
'Socrata': 'pygeoapi.provider.socrata.SODAServiceProvider',
|
||||
'Speckle': 'pygeoapi.provider.speckle.SpeckleProvider',
|
||||
'TinyDB': 'pygeoapi.provider.tinydb_.TinyDBProvider',
|
||||
'TinyDBCatalogue': 'pygeoapi.provider.tinydb_.TinyDBCatalogueProvider',
|
||||
'WMSFacade': 'pygeoapi.provider.wms_facade.WMSFacadeProvider',
|
||||
|
||||
@@ -54,6 +54,7 @@ from pygeoapi.util import (
|
||||
JobStatus,
|
||||
ProcessExecutionMode,
|
||||
RequestedProcessExecutionMode,
|
||||
RequestedResponse,
|
||||
Subscriber
|
||||
)
|
||||
|
||||
@@ -107,14 +108,21 @@ class BaseManager:
|
||||
else:
|
||||
return load_plugin('process', process_conf['processor'])
|
||||
|
||||
def get_jobs(self, status: JobStatus = None) -> list:
|
||||
def get_jobs(self,
|
||||
status: JobStatus = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Get process jobs, optionally filtered by status
|
||||
|
||||
:param status: job status (accepted, running, successful,
|
||||
failed, results) (default is all)
|
||||
:param limit: number of jobs to return
|
||||
:param offset: pagination offset
|
||||
|
||||
:returns: `list` of jobs (identifier, status, process identifier)
|
||||
:returns: dict of list of jobs (identifier, status, process identifier)
|
||||
and numberMatched
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
@@ -187,6 +195,7 @@ class BaseManager:
|
||||
data_dict: dict,
|
||||
requested_outputs: Optional[dict] = None,
|
||||
subscriber: Optional[Subscriber] = None,
|
||||
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
|
||||
) -> Tuple[str, None, JobStatus]:
|
||||
"""
|
||||
This private execution handler executes a process in a background
|
||||
@@ -197,27 +206,34 @@ class BaseManager:
|
||||
:param p: `pygeoapi.process` object
|
||||
:param job_id: job identifier
|
||||
:param data_dict: `dict` of data parameters
|
||||
:param requested_outputs: `dict` specify the subset of required
|
||||
outputs - defaults to all outputs.
|
||||
The value of any key may be an object and include the property
|
||||
`transmissionMode` - defaults to `value`.
|
||||
Note: 'optional' is for backward compatibility.
|
||||
:param requested_outputs: `dict` optionally specifying the subset of
|
||||
required outputs - defaults to all outputs.
|
||||
The value of any key may be an object and
|
||||
include the property `transmissionMode`
|
||||
(defaults to `value`)
|
||||
Note: 'optional' is for backward
|
||||
compatibility.
|
||||
:param subscriber: optional `Subscriber` specifying callback URLs
|
||||
:param requested_response: `RequestedResponse` optionally specifying
|
||||
raw or document (default is `raw`)
|
||||
|
||||
:returns: tuple of None (i.e. initial response payload)
|
||||
and JobStatus.accepted (i.e. initial job status)
|
||||
"""
|
||||
_process = dummy.Process(
|
||||
target=self._execute_handler_sync,
|
||||
args=(p, job_id, data_dict, requested_outputs, subscriber)
|
||||
)
|
||||
|
||||
args = (p, job_id, data_dict, requested_outputs, subscriber,
|
||||
requested_response)
|
||||
|
||||
_process = dummy.Process(target=self._execute_handler_sync, args=args)
|
||||
_process.start()
|
||||
|
||||
return 'application/json', None, JobStatus.accepted
|
||||
|
||||
def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
|
||||
data_dict: dict,
|
||||
requested_outputs: Optional[dict] = None,
|
||||
subscriber: Optional[Subscriber] = None,
|
||||
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
|
||||
) -> Tuple[str, Any, JobStatus]:
|
||||
"""
|
||||
Synchronous execution handler
|
||||
@@ -229,15 +245,27 @@ class BaseManager:
|
||||
:param p: `pygeoapi.process` object
|
||||
:param job_id: job identifier
|
||||
:param data_dict: `dict` of data parameters
|
||||
:param requested_outputs: `dict` specify the subset of required
|
||||
outputs - defaults to all outputs.
|
||||
The value of any key may be an object and include the property
|
||||
`transmissionMode` - defaults to `value`.
|
||||
Note: 'optional' is for backward compatibility.
|
||||
:param requested_outputs: `dict` optionally specifying the subset of
|
||||
required outputs - defaults to all outputs.
|
||||
The value of any key may be an object and
|
||||
include the property `transmissionMode`
|
||||
(defaults to `value`)
|
||||
Note: 'optional' is for backward
|
||||
compatibility.
|
||||
:param subscriber: optional `Subscriber` specifying callback URLs
|
||||
:param requested_response: `RequestedResponse` optionally specifying
|
||||
raw or document (default is `raw`)
|
||||
|
||||
:returns: tuple of MIME type, response payload and status
|
||||
"""
|
||||
|
||||
extra_execute_parameters = {}
|
||||
|
||||
# only pass requested_outputs if supported,
|
||||
# otherwise this breaks existing processes
|
||||
if p.supports_outputs:
|
||||
extra_execute_parameters['outputs'] = requested_outputs
|
||||
|
||||
self._send_in_progress_notification(subscriber)
|
||||
|
||||
try:
|
||||
@@ -248,13 +276,12 @@ class BaseManager:
|
||||
job_filename = None
|
||||
|
||||
current_status = JobStatus.running
|
||||
jfmt, outputs = p.execute(
|
||||
data_dict,
|
||||
# only pass requested_outputs if supported,
|
||||
# otherwise this breaks existing processes
|
||||
**({'outputs': requested_outputs}
|
||||
if p.supports_outputs else {})
|
||||
)
|
||||
jfmt, outputs = p.execute(data_dict, **extra_execute_parameters)
|
||||
|
||||
if requested_response == RequestedResponse.document.value:
|
||||
outputs = {
|
||||
'outputs': [outputs]
|
||||
}
|
||||
|
||||
self.update_job(job_id, {
|
||||
'status': current_status.value,
|
||||
@@ -330,7 +357,8 @@ class BaseManager:
|
||||
data_dict: dict,
|
||||
execution_mode: Optional[RequestedProcessExecutionMode] = None,
|
||||
requested_outputs: Optional[dict] = None,
|
||||
subscriber: Optional[Subscriber] = None
|
||||
subscriber: Optional[Subscriber] = None,
|
||||
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
|
||||
) -> Tuple[str, Any, JobStatus, Optional[Dict[str, str]]]:
|
||||
"""
|
||||
Default process execution handler
|
||||
@@ -339,12 +367,17 @@ class BaseManager:
|
||||
:param data_dict: `dict` of data parameters
|
||||
:param execution_mode: `str` optionally specifying sync or async
|
||||
processing.
|
||||
:param requested_outputs: `dict` optionally specify the subset of
|
||||
required outputs - defaults to all outputs.
|
||||
The value of any key may be an object and include the property
|
||||
`transmissionMode` - defaults to `value`.
|
||||
Note: 'optional' is for backward compatibility.
|
||||
:param requested_outputs: `dict` optionally specifying the subset of
|
||||
required outputs - defaults to all outputs.
|
||||
The value of any key may be an object and
|
||||
include the property `transmissionMode`
|
||||
(default is `value`)
|
||||
Note: 'optional' is for backward
|
||||
compatibility.
|
||||
:param subscriber: `Subscriber` optionally specifying callback urls
|
||||
:param requested_response: `RequestedResponse` optionally specifying
|
||||
raw or document (default is `raw`)
|
||||
|
||||
|
||||
:raises UnknownProcessError: if the input process_id does not
|
||||
correspond to a known process
|
||||
@@ -356,6 +389,9 @@ class BaseManager:
|
||||
job_id = str(uuid.uuid1())
|
||||
processor = self.get_processor(process_id)
|
||||
processor.set_job_id(job_id)
|
||||
extra_execute_handler_parameters = {
|
||||
'requested_response': requested_response
|
||||
}
|
||||
|
||||
if execution_mode == RequestedProcessExecutionMode.respond_async:
|
||||
job_control_options = processor.metadata.get(
|
||||
@@ -406,6 +442,11 @@ class BaseManager:
|
||||
}
|
||||
self.add_job(job_metadata)
|
||||
|
||||
# only pass subscriber if supported, otherwise this breaks
|
||||
# existing managers
|
||||
if self.supports_subscribing:
|
||||
extra_execute_handler_parameters['subscriber'] = subscriber
|
||||
|
||||
# TODO: handler's response could also be allowed to include more HTTP
|
||||
# headers
|
||||
mime_type, outputs, status = handler(
|
||||
@@ -413,10 +454,7 @@ class BaseManager:
|
||||
job_id,
|
||||
data_dict,
|
||||
requested_outputs,
|
||||
# only pass subscriber if supported, otherwise this breaks existing
|
||||
# managers
|
||||
**({'subscriber': subscriber} if self.supports_subscribing else {})
|
||||
)
|
||||
**extra_execute_handler_parameters)
|
||||
|
||||
return job_id, mime_type, outputs, status, response_headers
|
||||
|
||||
|
||||
@@ -33,8 +33,9 @@ import uuid
|
||||
|
||||
from pygeoapi.process.manager.base import BaseManager
|
||||
from pygeoapi.util import (
|
||||
RequestedProcessExecutionMode,
|
||||
JobStatus,
|
||||
RequestedProcessExecutionMode,
|
||||
RequestedResponse,
|
||||
Subscriber
|
||||
)
|
||||
|
||||
@@ -55,17 +56,21 @@ class DummyManager(BaseManager):
|
||||
|
||||
super().__init__(manager_def)
|
||||
|
||||
def get_jobs(self, status: JobStatus = None) -> list:
|
||||
def get_jobs(self, status: JobStatus = None, limit=None, offset=None
|
||||
) -> dict:
|
||||
"""
|
||||
Get process jobs, optionally filtered by status
|
||||
|
||||
:param status: job status (accepted, running, successful,
|
||||
failed, results) (default is all)
|
||||
:param limit: number of jobs to return
|
||||
:param offset: pagination offset
|
||||
|
||||
:returns: `list` of jobs (identifier, status, process identifier)
|
||||
:returns: dict of list of jobs (identifier, status, process identifier)
|
||||
and numberMatched
|
||||
"""
|
||||
|
||||
return []
|
||||
return {'jobs': [], 'numberMatched': 0}
|
||||
|
||||
def execute_process(
|
||||
self,
|
||||
@@ -73,7 +78,8 @@ class DummyManager(BaseManager):
|
||||
data_dict: dict,
|
||||
execution_mode: Optional[RequestedProcessExecutionMode] = None,
|
||||
requested_outputs: Optional[dict] = None,
|
||||
subscriber: Optional[Subscriber] = None
|
||||
subscriber: Optional[Subscriber] = None,
|
||||
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
|
||||
) -> Tuple[str, str, Any, JobStatus, Optional[Dict[str, str]]]:
|
||||
"""
|
||||
Default process execution handler
|
||||
@@ -81,9 +87,19 @@ class DummyManager(BaseManager):
|
||||
:param process_id: process identifier
|
||||
:param data_dict: `dict` of data parameters
|
||||
:param execution_mode: requested execution mode
|
||||
:param requested_outputs: `dict` optionally specify the subset of
|
||||
required outputs - defaults to all outputs.
|
||||
The value of any key may be an object and include the property
|
||||
`transmissionMode` - defaults to `value`.
|
||||
Note: 'optional' is for backward compatibility.
|
||||
:param subscriber: `Subscriber` optionally specifying callback urls
|
||||
:param requested_response: `RequestedResponse` optionally specifying
|
||||
raw or document (default is `raw`)
|
||||
|
||||
:raises UnknownProcessError: if the input process_id does not
|
||||
correspond to a known process
|
||||
:returns: tuple of job_id, MIME type, response payload, status and
|
||||
optionally additional HTTP headers to include in the
|
||||
optionally additional HTTP headers to include in the final
|
||||
response
|
||||
"""
|
||||
|
||||
@@ -100,7 +116,8 @@ class DummyManager(BaseManager):
|
||||
self._send_in_progress_notification(subscriber)
|
||||
processor = self.get_processor(process_id)
|
||||
try:
|
||||
jfmt, outputs = processor.execute(data_dict)
|
||||
jfmt, outputs = processor.execute(
|
||||
data_dict, outputs=requested_outputs)
|
||||
current_status = JobStatus.successful
|
||||
self._send_success_notification(subscriber, outputs)
|
||||
except Exception as err:
|
||||
@@ -111,6 +128,12 @@ class DummyManager(BaseManager):
|
||||
current_status = JobStatus.failed
|
||||
LOGGER.exception(err)
|
||||
self._send_failed_notification(subscriber)
|
||||
|
||||
if requested_response == RequestedResponse.document.value:
|
||||
outputs = {
|
||||
'outputs': [outputs]
|
||||
}
|
||||
|
||||
job_id = str(uuid.uuid1())
|
||||
return job_id, jfmt, outputs, current_status, response_headers
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import traceback
|
||||
|
||||
from pymongo import MongoClient
|
||||
|
||||
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
|
||||
from pygeoapi.process.base import (
|
||||
JobNotFoundError,
|
||||
JobResultNotFoundError,
|
||||
@@ -70,7 +71,7 @@ class MongoDBManager(BaseManager):
|
||||
exc_info=(traceback))
|
||||
return False
|
||||
|
||||
def get_jobs(self, status=None):
|
||||
def get_jobs(self, status=None, limit=None, offset=None):
|
||||
try:
|
||||
self._connect()
|
||||
database = self.db.job_manager_pygeoapi
|
||||
@@ -80,7 +81,10 @@ class MongoDBManager(BaseManager):
|
||||
else:
|
||||
jobs = list(collection.find({}))
|
||||
LOGGER.info("JOBMANAGER - MongoDB jobs queried")
|
||||
return jobs
|
||||
return {
|
||||
'jobs': jobs,
|
||||
'numberMatched': len(jobs)
|
||||
}
|
||||
except Exception:
|
||||
LOGGER.error("JOBMANAGER - get_jobs error",
|
||||
exc_info=(traceback))
|
||||
@@ -148,8 +152,16 @@ class MongoDBManager(BaseManager):
|
||||
if entry["status"] != "successful":
|
||||
LOGGER.info("JOBMANAGER - job not finished or failed")
|
||||
return (None,)
|
||||
with open(entry["location"], "r") as file:
|
||||
data = json.load(file)
|
||||
if not entry["location"]:
|
||||
LOGGER.warning(f"job {job_id!r} - unknown result location")
|
||||
raise JobResultNotFoundError()
|
||||
if entry["mimetype"] in (None, FORMAT_TYPES[F_JSON],
|
||||
FORMAT_TYPES[F_JSONLD]):
|
||||
with open(entry["location"], "r") as file:
|
||||
data = json.load(file)
|
||||
else:
|
||||
with open(entry["location"], "rb") as file:
|
||||
data = file.read()
|
||||
LOGGER.info("JOBMANAGER - MongoDB job result queried")
|
||||
return entry["mimetype"], data
|
||||
except Exception as err:
|
||||
|
||||
@@ -46,8 +46,10 @@ from pathlib import Path
|
||||
from typing import Any, Tuple
|
||||
|
||||
from sqlalchemy import insert, update, delete
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
|
||||
from pygeoapi.process.base import (
|
||||
JobNotFoundError,
|
||||
JobResultNotFoundError,
|
||||
@@ -83,12 +85,18 @@ class PostgreSQLManager(BaseManager):
|
||||
self.db_search_path = tuple(self.connection.get('search_path',
|
||||
['public']))
|
||||
except Exception:
|
||||
self.db_search_path = 'public'
|
||||
self.db_search_path = ('public',)
|
||||
|
||||
try:
|
||||
LOGGER.debug('Connecting to database')
|
||||
if isinstance(self.connection, str):
|
||||
self._engine = get_engine(self.connection)
|
||||
_url = make_url(self.connection)
|
||||
self._engine = get_engine(
|
||||
_url.host,
|
||||
_url.port,
|
||||
_url.database,
|
||||
_url.username,
|
||||
_url.password)
|
||||
else:
|
||||
self._engine = get_engine(**self.connection)
|
||||
except Exception as err:
|
||||
@@ -109,16 +117,18 @@ class PostgreSQLManager(BaseManager):
|
||||
LOGGER.error(f'{msg}: {err}')
|
||||
raise ProcessorGenericError(msg)
|
||||
|
||||
def get_jobs(self, status: JobStatus = None) -> list:
|
||||
def get_jobs(self, status: JobStatus = None, limit=None, offset=None
|
||||
) -> dict:
|
||||
"""
|
||||
Get jobs
|
||||
|
||||
:param status: job status (accepted, running, successful,
|
||||
failed, results) (default is all)
|
||||
:param limit: number of jobs to return
|
||||
:param offset: pagination offset
|
||||
|
||||
:returns: 'list` of jobs (type (default='process'), identifier,
|
||||
status, process_id, job_start_datetime, job_end_datetime, location,
|
||||
mimetype, message, progress)
|
||||
:returns: dict of list of jobs (identifier, status, process identifier)
|
||||
and numberMatched
|
||||
"""
|
||||
|
||||
LOGGER.debug('Querying for jobs')
|
||||
@@ -128,7 +138,11 @@ class PostgreSQLManager(BaseManager):
|
||||
column = getattr(self.table_model, 'status')
|
||||
results = results.filter(column == status.value)
|
||||
|
||||
return [r.__dict__ for r in results.all()]
|
||||
jobs = [r.__dict__ for r in results.all()]
|
||||
return {
|
||||
'jobs': jobs,
|
||||
'numberMatched': len(jobs)
|
||||
}
|
||||
|
||||
def add_job(self, job_metadata: dict) -> str:
|
||||
"""
|
||||
@@ -279,8 +293,13 @@ class PostgreSQLManager(BaseManager):
|
||||
else:
|
||||
try:
|
||||
location = Path(location)
|
||||
with location.open(encoding='utf-8') as fh:
|
||||
result = json.load(fh)
|
||||
if mimetype in (None, FORMAT_TYPES[F_JSON],
|
||||
FORMAT_TYPES[F_JSONLD]):
|
||||
with location.open('r', encoding='utf-8') as fh:
|
||||
result = json.load(fh)
|
||||
else:
|
||||
with location.open('rb') as fh:
|
||||
result = fh.read()
|
||||
except (TypeError, FileNotFoundError, json.JSONDecodeError):
|
||||
raise JobResultNotFoundError()
|
||||
else:
|
||||
|
||||
@@ -37,6 +37,7 @@ from typing import Any, Tuple
|
||||
import tinydb
|
||||
from filelock import FileLock
|
||||
|
||||
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
|
||||
from pygeoapi.process.base import (
|
||||
JobNotFoundError,
|
||||
JobResultNotFoundError,
|
||||
@@ -82,20 +83,35 @@ class TinyDBManager(BaseManager):
|
||||
|
||||
return True
|
||||
|
||||
def get_jobs(self, status: JobStatus = None) -> list:
|
||||
def get_jobs(self, status: JobStatus = None, limit=None, offset=None
|
||||
) -> dict:
|
||||
"""
|
||||
Get jobs
|
||||
|
||||
:param status: job status (accepted, running, successful,
|
||||
failed, results) (default is all)
|
||||
:param limit: number of jobs to return
|
||||
:param offset: pagination offset
|
||||
|
||||
:returns: 'list` of jobs (identifier, status, process identifier)
|
||||
:returns: dict of list of jobs (identifier, status, process identifier)
|
||||
and numberMatched
|
||||
"""
|
||||
|
||||
with self._db() as db:
|
||||
jobs_list = db.all()
|
||||
|
||||
return jobs_list
|
||||
number_matched = len(jobs_list)
|
||||
|
||||
if offset:
|
||||
jobs_list = jobs_list[offset:]
|
||||
|
||||
if limit:
|
||||
jobs_list = jobs_list[:limit]
|
||||
|
||||
return {
|
||||
'jobs': jobs_list,
|
||||
'numberMatched': number_matched
|
||||
}
|
||||
|
||||
def add_job(self, job_metadata: dict) -> str:
|
||||
"""
|
||||
@@ -196,8 +212,13 @@ class TinyDBManager(BaseManager):
|
||||
else:
|
||||
try:
|
||||
location = Path(location)
|
||||
with location.open('r', encoding='utf-8') as filehandler:
|
||||
result = json.load(filehandler)
|
||||
if mimetype in (None, FORMAT_TYPES[F_JSON],
|
||||
FORMAT_TYPES[F_JSONLD]):
|
||||
with location.open('r', encoding='utf-8') as filehandler:
|
||||
result = json.load(filehandler)
|
||||
else:
|
||||
with location.open('rb') as filehandler:
|
||||
result = filehandler.read()
|
||||
except (TypeError, FileNotFoundError, json.JSONDecodeError):
|
||||
raise JobResultNotFoundError()
|
||||
else:
|
||||
|
||||
@@ -73,7 +73,7 @@ class BaseProvider:
|
||||
self.title_field = provider_def.get('title_field')
|
||||
self.properties = provider_def.get('properties', [])
|
||||
self.file_types = provider_def.get('file_types', [])
|
||||
self.fields = {}
|
||||
self._fields = {}
|
||||
self.filename = None
|
||||
|
||||
# for coverage providers
|
||||
@@ -85,13 +85,31 @@ class BaseProvider:
|
||||
"""
|
||||
Get provider field information (names, types)
|
||||
|
||||
Example response: {'field1': 'string', 'field2': 'number'}}
|
||||
Example response:
|
||||
{'field1': {'type': 'string'}, 'field2': {'type': 'number'}}
|
||||
|
||||
:returns: dict of field names and their associated JSON Schema types
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def fields(self) -> dict:
|
||||
"""
|
||||
Store provider field information (names, types)
|
||||
|
||||
Example response:
|
||||
{'field1': {'type': 'string'}, 'field2': {'type': 'number'}}
|
||||
|
||||
:returns: dict of dicts (field names and their
|
||||
associated JSON Schema definitions)
|
||||
"""
|
||||
|
||||
if hasattr(self, '_fields'):
|
||||
return self._fields
|
||||
else:
|
||||
return self.get_fields()
|
||||
|
||||
def get_schema(self, schema_type: SchemaType = SchemaType.item):
|
||||
"""
|
||||
Get provider schema model
|
||||
|
||||
@@ -29,10 +29,14 @@
|
||||
|
||||
import logging
|
||||
|
||||
from pygeoapi.provider.base import BaseProvider
|
||||
from pygeoapi.provider.base import BaseProvider, ProviderInvalidDataError
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
EDR_QUERY_TYPES = ['position', 'radius', 'area', 'cube',
|
||||
'trajectory', 'corridor', 'items',
|
||||
'locations', 'instances']
|
||||
|
||||
|
||||
class BaseEDRProvider(BaseProvider):
|
||||
"""Base EDR Provider"""
|
||||
@@ -55,6 +59,11 @@ class BaseEDRProvider(BaseProvider):
|
||||
@classmethod
|
||||
def register(cls):
|
||||
def inner(fn):
|
||||
if fn.__name__ not in EDR_QUERY_TYPES:
|
||||
msg = 'Invalid EDR Query type'
|
||||
LOGGER.error(msg)
|
||||
raise ProviderInvalidDataError(msg)
|
||||
|
||||
cls.query_types.append(fn.__name__)
|
||||
return fn
|
||||
return inner
|
||||
|
||||
+22
-23
@@ -54,7 +54,7 @@ class CSVProvider(BaseProvider):
|
||||
super().__init__(provider_def)
|
||||
self.geometry_x = provider_def['geometry']['x_field']
|
||||
self.geometry_y = provider_def['geometry']['y_field']
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
@@ -62,32 +62,31 @@ class CSVProvider(BaseProvider):
|
||||
|
||||
:returns: dict of fields
|
||||
"""
|
||||
if not self._fields:
|
||||
LOGGER.debug('Treating all columns as string types')
|
||||
with open(self.data) as ff:
|
||||
LOGGER.debug('Serializing DictReader')
|
||||
data_ = csv.DictReader(ff)
|
||||
|
||||
LOGGER.debug('Treating all columns as string types')
|
||||
with open(self.data) as ff:
|
||||
LOGGER.debug('Serializing DictReader')
|
||||
data_ = csv.DictReader(ff)
|
||||
fields = {}
|
||||
row = next(data_)
|
||||
|
||||
row = next(data_)
|
||||
for key, value in row.items():
|
||||
LOGGER.debug(f'key: {key}, value: {value}')
|
||||
value2 = get_typed_value(value)
|
||||
if key in [self.geometry_x, self.geometry_y]:
|
||||
continue
|
||||
if key == self.id_field:
|
||||
type_ = 'string'
|
||||
elif isinstance(value2, float):
|
||||
type_ = 'number'
|
||||
elif isinstance(value2, int):
|
||||
type_ = 'integer'
|
||||
else:
|
||||
type_ = 'string'
|
||||
|
||||
for key, value in row.items():
|
||||
LOGGER.debug(f'key: {key}, value: {value}')
|
||||
value2 = get_typed_value(value)
|
||||
if key in [self.geometry_x, self.geometry_y]:
|
||||
continue
|
||||
if key == self.id_field:
|
||||
type_ = 'string'
|
||||
elif isinstance(value2, float):
|
||||
type_ = 'number'
|
||||
elif isinstance(value2, int):
|
||||
type_ = 'integer'
|
||||
else:
|
||||
type_ = 'string'
|
||||
self._fields[key] = {'type': type_}
|
||||
|
||||
fields[key] = {'type': type_}
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def _load(self, offset=0, limit=10, resulttype='results',
|
||||
identifier=None, bbox=[], datetime_=None, properties=[],
|
||||
|
||||
@@ -69,7 +69,8 @@ class CSWFacadeProvider(BaseProvider):
|
||||
'language': ('dc:language', 'language')
|
||||
}
|
||||
|
||||
self.fields = self.get_fields()
|
||||
self._fields = {}
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
@@ -78,17 +79,17 @@ class CSWFacadeProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
fields = {}
|
||||
date_fields = ['date', 'created', 'updated']
|
||||
if not self._fields:
|
||||
date_fields = ['date', 'created', 'updated']
|
||||
|
||||
for key in self.record_mappings.keys():
|
||||
LOGGER.debug(f'key: {key}')
|
||||
fields[key] = {'type': 'string'}
|
||||
for key in self.record_mappings.keys():
|
||||
LOGGER.debug(f'key: {key}')
|
||||
self._fields[key] = {'type': 'string'}
|
||||
|
||||
if key in date_fields:
|
||||
fields[key]['format'] = 'date-time'
|
||||
if key in date_fields:
|
||||
self._fields[key]['format'] = 'date-time'
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
|
||||
@@ -87,7 +87,7 @@ class ElasticsearchProvider(BaseProvider):
|
||||
|
||||
LOGGER.debug('Grabbing field information')
|
||||
try:
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
except exceptions.NotFoundError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderQueryError(err)
|
||||
@@ -98,38 +98,40 @@ class ElasticsearchProvider(BaseProvider):
|
||||
|
||||
:returns: dict of fields
|
||||
"""
|
||||
if not self._fields:
|
||||
ii = self.es.indices.get(index=self.index_name,
|
||||
allow_no_indices=False)
|
||||
|
||||
fields_ = {}
|
||||
ii = self.es.indices.get(index=self.index_name, allow_no_indices=False)
|
||||
|
||||
LOGGER.debug(f'Response: {ii}')
|
||||
try:
|
||||
if '*' not in self.index_name:
|
||||
p = ii[self.index_name]['mappings']['properties']['properties']
|
||||
else:
|
||||
LOGGER.debug('Wildcard index; setting from first match')
|
||||
index_name_ = list(ii.keys())[0]
|
||||
p = ii[index_name_]['mappings']['properties']['properties']
|
||||
except KeyError:
|
||||
LOGGER.warning('Trying for alias')
|
||||
alias_name = next(iter(ii))
|
||||
p = ii[alias_name]['mappings']['properties']['properties']
|
||||
except IndexError:
|
||||
LOGGER.warning('could not get fields; returning empty set')
|
||||
return {}
|
||||
|
||||
for k, v in p['properties'].items():
|
||||
if 'type' in v:
|
||||
if v['type'] == 'text':
|
||||
fields_[k] = {'type': 'string'}
|
||||
elif v['type'] == 'date':
|
||||
fields_[k] = {'type': 'string', 'format': 'date'}
|
||||
elif v['type'] in ('float', 'long'):
|
||||
fields_[k] = {'type': 'number', 'format': v['type']}
|
||||
LOGGER.debug(f'Response: {ii}')
|
||||
try:
|
||||
if '*' not in self.index_name:
|
||||
mappings = ii[self.index_name]['mappings']
|
||||
p = mappings['properties']['properties']
|
||||
else:
|
||||
fields_[k] = {'type': v['type']}
|
||||
LOGGER.debug('Wildcard index; setting from first match')
|
||||
index_name_ = list(ii.keys())[0]
|
||||
p = ii[index_name_]['mappings']['properties']['properties']
|
||||
except KeyError:
|
||||
LOGGER.warning('Trying for alias')
|
||||
alias_name = next(iter(ii))
|
||||
p = ii[alias_name]['mappings']['properties']['properties']
|
||||
except IndexError:
|
||||
LOGGER.warning('could not get fields; returning empty set')
|
||||
return {}
|
||||
|
||||
return fields_
|
||||
for k, v in p['properties'].items():
|
||||
if 'type' in v:
|
||||
if v['type'] == 'text':
|
||||
self._fields[k] = {'type': 'string'}
|
||||
elif v['type'] == 'date':
|
||||
self._fields[k] = {'type': 'string', 'format': 'date'}
|
||||
elif v['type'] in ('float', 'long'):
|
||||
self._fields[k] = {'type': 'number',
|
||||
'format': v['type']}
|
||||
else:
|
||||
self._fields[k] = {'type': v['type']}
|
||||
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
|
||||
+13
-12
@@ -62,24 +62,25 @@ class TabledapProvider(BaseProvider):
|
||||
|
||||
LOGGER.debug('Setting provider query filters')
|
||||
self.filters = self.options.get('filters')
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
LOGGER.debug('Fetching one feature for field definitions')
|
||||
properties = self.query(limit=1)['features'][0]['properties']
|
||||
if not self._fields:
|
||||
LOGGER.debug('Fetching one feature for field definitions')
|
||||
properties = self.query(limit=1)['features'][0]['properties']
|
||||
|
||||
for key, value in properties.items():
|
||||
LOGGER.debug(f'Field: {key}={value}')
|
||||
for key, value in properties.items():
|
||||
LOGGER.debug(f'Field: {key}={value}')
|
||||
|
||||
data_type = type(value).__name__
|
||||
data_type = type(value).__name__
|
||||
|
||||
if data_type == 'str':
|
||||
data_type = 'string'
|
||||
if data_type == 'float':
|
||||
data_type = 'number'
|
||||
properties[key] = {'type': data_type}
|
||||
if data_type == 'str':
|
||||
data_type = 'string'
|
||||
if data_type == 'float':
|
||||
data_type = 'number'
|
||||
self._fields[key] = {'type': data_type}
|
||||
|
||||
return properties
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
|
||||
@@ -62,8 +62,9 @@ class ESRIServiceProvider(BaseProvider):
|
||||
self.crs = provider_def.get('crs', '4326')
|
||||
self.username = provider_def.get('username')
|
||||
self.password = provider_def.get('password')
|
||||
self.token_url = provider_def.get('token_service', ARCGIS_URL)
|
||||
self.token_referer = provider_def.get('referer', GENERATE_TOKEN_URL)
|
||||
self.token = None
|
||||
|
||||
self.session = Session()
|
||||
|
||||
self.login()
|
||||
@@ -76,7 +77,7 @@ class ESRIServiceProvider(BaseProvider):
|
||||
:returns: `dict` of fields
|
||||
"""
|
||||
|
||||
if not self.fields:
|
||||
if not self._fields:
|
||||
# Load fields
|
||||
params = {'f': 'pjson'}
|
||||
resp = self.get_response(self.data, params=params)
|
||||
@@ -102,9 +103,9 @@ class ESRIServiceProvider(BaseProvider):
|
||||
raise ProviderTypeError(msg)
|
||||
|
||||
for _ in resp['fields']:
|
||||
self.fields.update({_['name']: {'type': _['type']}})
|
||||
self._fields.update({_['name']: {'type': _['type']}})
|
||||
|
||||
return self.fields
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
@@ -194,16 +195,15 @@ class ESRIServiceProvider(BaseProvider):
|
||||
msg = 'Missing ESRI login information, not setting token'
|
||||
LOGGER.debug(msg)
|
||||
return
|
||||
|
||||
params = {
|
||||
'f': 'pjson',
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'referer': ARCGIS_URL
|
||||
'referer': self.token_referer
|
||||
}
|
||||
|
||||
LOGGER.debug('Logging in')
|
||||
with self.session.post(GENERATE_TOKEN_URL, data=params) as r:
|
||||
with self.session.post(self.token_url, data=params) as r:
|
||||
self.token = r.json().get('token')
|
||||
# https://enterprise.arcgis.com/en/server/latest/administer/windows/about-arcgis-tokens.htm
|
||||
self.session.headers.update({
|
||||
|
||||
@@ -68,7 +68,7 @@ class GeoJSONProvider(BaseProvider):
|
||||
"""initializer"""
|
||||
|
||||
super().__init__(provider_def)
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
@@ -77,23 +77,24 @@ class GeoJSONProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
fields = {}
|
||||
LOGGER.debug('Treating all columns as string types')
|
||||
if os.path.exists(self.data):
|
||||
with open(self.data) as src:
|
||||
data = json.loads(src.read())
|
||||
for key, value in data['features'][0]['properties'].items():
|
||||
if isinstance(value, float):
|
||||
type_ = 'number'
|
||||
elif isinstance(value, int):
|
||||
type_ = 'integer'
|
||||
else:
|
||||
type_ = 'string'
|
||||
if not self._fields:
|
||||
LOGGER.debug('Treating all columns as string types')
|
||||
if os.path.exists(self.data):
|
||||
with open(self.data) as src:
|
||||
data = json.loads(src.read())
|
||||
for key, value in data['features'][0]['properties'].items():
|
||||
if isinstance(value, float):
|
||||
type_ = 'number'
|
||||
elif isinstance(value, int):
|
||||
type_ = 'integer'
|
||||
else:
|
||||
type_ = 'string'
|
||||
|
||||
fields[key] = {'type': type_}
|
||||
else:
|
||||
LOGGER.warning(f'File {self.data} does not exist.')
|
||||
return fields
|
||||
self._fields[key] = {'type': type_}
|
||||
else:
|
||||
LOGGER.warning(f'File {self.data} does not exist.')
|
||||
|
||||
return self._fields
|
||||
|
||||
def _load(self, skip_geometry=None, properties=[], select_properties=[]):
|
||||
"""Load and validate the source GeoJSON file
|
||||
|
||||
+16
-17
@@ -66,7 +66,7 @@ class MongoProvider(BaseProvider):
|
||||
self.featuredb = dbclient.get_default_database()
|
||||
self.collection = provider_def['collection']
|
||||
self.featuredb[self.collection].create_index([("geometry", GEOSPHERE)])
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
@@ -75,25 +75,24 @@ class MongoProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
pipeline = [
|
||||
{"$project": {"properties": 1}},
|
||||
{"$unwind": "$properties"},
|
||||
{"$group": {"_id": "$properties", "count": {"$sum": 1}}},
|
||||
{"$project": {"_id": 1}}
|
||||
]
|
||||
if not self._fields:
|
||||
pipeline = [
|
||||
{"$project": {"properties": 1}},
|
||||
{"$unwind": "$properties"},
|
||||
{"$group": {"_id": "$properties", "count": {"$sum": 1}}},
|
||||
{"$project": {"_id": 1}}
|
||||
]
|
||||
|
||||
result = list(self.featuredb[self.collection].aggregate(pipeline))
|
||||
result = list(self.featuredb[self.collection].aggregate(pipeline))
|
||||
|
||||
# prepare a dictionary with fields
|
||||
# set the field type to 'string'.
|
||||
# by operating without a schema, mongo can query any data type.
|
||||
fields = {}
|
||||
# prepare a dictionary with fields
|
||||
# set the field type to 'string'.
|
||||
# by operating without a schema, mongo can query any data type.
|
||||
for i in result:
|
||||
for key in result[0]['_id'].keys():
|
||||
self._fields[key] = {'type': 'string'}
|
||||
|
||||
for i in result:
|
||||
for key in result[0]['_id'].keys():
|
||||
fields[key] = {'type': 'string'}
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def _get_feature_list(self, filterObj, sortList=[], skip=0, maxitems=1,
|
||||
skip_geometry=False):
|
||||
|
||||
+30
-30
@@ -188,7 +188,7 @@ class OGRProvider(BaseProvider):
|
||||
self.conn = None
|
||||
|
||||
LOGGER.debug('Grabbing field information')
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def _list_open_options(self):
|
||||
return [
|
||||
@@ -260,43 +260,43 @@ class OGRProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
fields = {}
|
||||
try:
|
||||
layer_defn = self._get_layer().GetLayerDefn()
|
||||
for fld in range(layer_defn.GetFieldCount()):
|
||||
field_defn = layer_defn.GetFieldDefn(fld)
|
||||
fieldName = field_defn.GetName()
|
||||
fieldTypeCode = field_defn.GetType()
|
||||
fieldType = field_defn.GetFieldTypeName(fieldTypeCode)
|
||||
if not self._fields:
|
||||
try:
|
||||
layer_defn = self._get_layer().GetLayerDefn()
|
||||
for fld in range(layer_defn.GetFieldCount()):
|
||||
field_defn = layer_defn.GetFieldDefn(fld)
|
||||
fieldName = field_defn.GetName()
|
||||
fieldTypeCode = field_defn.GetType()
|
||||
fieldType = field_defn.GetFieldTypeName(fieldTypeCode)
|
||||
|
||||
fieldName2 = fieldType.lower()
|
||||
fieldName2 = fieldType.lower()
|
||||
|
||||
if fieldName2 == 'integer64':
|
||||
fieldName2 = 'integer'
|
||||
elif fieldName2 == 'real':
|
||||
fieldName2 = 'number'
|
||||
if fieldName2 == 'integer64':
|
||||
fieldName2 = 'integer'
|
||||
elif fieldName2 == 'real':
|
||||
fieldName2 = 'number'
|
||||
|
||||
fields[fieldName] = {'type': fieldName2}
|
||||
self._fields[fieldName] = {'type': fieldName2}
|
||||
|
||||
if fieldName2 == 'datetime':
|
||||
fields[fieldName] = {
|
||||
'type': 'string',
|
||||
'format': 'date-time'
|
||||
}
|
||||
if fieldName2 == 'datetime':
|
||||
self._fields[fieldName] = {
|
||||
'type': 'string',
|
||||
'format': 'date-time'
|
||||
}
|
||||
|
||||
# fieldWidth = layer_defn.GetFieldDefn(fld).GetWidth()
|
||||
# GetPrecision = layer_defn.GetFieldDefn(fld).GetPrecision()
|
||||
# fieldWidth = layer_defn.GetFieldDefn(fld).GetWidth()
|
||||
# GetPrecision = layer_defn.GetFieldDefn(fld).GetPrecision() # noqa
|
||||
|
||||
except RuntimeError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderConnectionError(err)
|
||||
except Exception as err:
|
||||
LOGGER.error(err)
|
||||
except RuntimeError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderConnectionError(err)
|
||||
except Exception as err:
|
||||
LOGGER.error(err)
|
||||
|
||||
finally:
|
||||
self._close()
|
||||
finally:
|
||||
self._close()
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
bbox=[], datetime_=None, properties=[], sortby=[],
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
# =================================================================
|
||||
#
|
||||
# Authors: Tom Kralidis <tomkralidis@gmail.com>
|
||||
# Francesco Bartoli <xbartolone@gmail.com>
|
||||
#
|
||||
# Copyright (c) 2024 Tom Kralidis
|
||||
# Copyright (c) 2024 Francesco Bartoli
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person
|
||||
# obtaining a copy of this software and associated documentation
|
||||
# files (the "Software"), to deal in the Software without
|
||||
# restriction, including without limitation the rights to use,
|
||||
# copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the
|
||||
# Software is furnished to do so, subject to the following
|
||||
# conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
# OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
# =================================================================
|
||||
|
||||
from typing import Dict
|
||||
from collections import OrderedDict
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from opensearchpy import OpenSearch, helpers
|
||||
from opensearch_dsl import Search, Q
|
||||
|
||||
from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError,
|
||||
ProviderQueryError,
|
||||
ProviderItemNotFoundError)
|
||||
from pygeoapi.models.cql import CQLModel, get_next_node
|
||||
from pygeoapi.util import get_envelope, crs_transform
|
||||
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenSearchProvider(BaseProvider):
|
||||
"""OpenSearch Provider"""
|
||||
|
||||
def __init__(self, provider_def):
|
||||
"""
|
||||
Initialize object
|
||||
|
||||
:param provider_def: provider definition
|
||||
|
||||
:returns: pygeoapi.provider.opensearch_.OpenSearchProvider
|
||||
"""
|
||||
|
||||
super().__init__(provider_def)
|
||||
|
||||
self.select_properties = []
|
||||
|
||||
self.os_host, self.index_name = self.data.rsplit('/', 1)
|
||||
|
||||
LOGGER.debug('Setting OpenSearch properties')
|
||||
|
||||
LOGGER.debug(f'host: {self.os_host}')
|
||||
LOGGER.debug(f'index: {self.index_name}')
|
||||
|
||||
LOGGER.debug('Connecting to OpenSearch')
|
||||
self.os_ = OpenSearch(self.os_host, verify_certs=0)
|
||||
if not self.os_.ping():
|
||||
msg = f'Cannot connect to OpenSearch: {self.os_host}'
|
||||
LOGGER.error(msg)
|
||||
raise ProviderConnectionError(msg)
|
||||
|
||||
LOGGER.debug('Determining OpenSearch version')
|
||||
v = self.os_.info()['version']['number'][:3]
|
||||
LOGGER.debug(f'OpenSearch version: {v}')
|
||||
|
||||
LOGGER.debug('Grabbing field information')
|
||||
try:
|
||||
self.get_fields()
|
||||
except Exception as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderQueryError(err)
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
Get provider field information (names, types)
|
||||
|
||||
:returns: dict of fields
|
||||
"""
|
||||
if not self._fields:
|
||||
ii = self.os_.indices.get(index=self.index_name,
|
||||
allow_no_indices=False)
|
||||
|
||||
LOGGER.debug(f'Response: {ii}')
|
||||
try:
|
||||
if '*' not in self.index_name:
|
||||
mappings = ii[self.index_name]['mappings']
|
||||
p = mappings['properties']['properties']
|
||||
else:
|
||||
LOGGER.debug('Wildcard index; setting from first match')
|
||||
index_name_ = list(ii.keys())[0]
|
||||
p = ii[index_name_]['mappings']['properties']['properties']
|
||||
except KeyError:
|
||||
LOGGER.warning('Trying for alias')
|
||||
alias_name = next(iter(ii))
|
||||
p = ii[alias_name]['mappings']['properties']['properties']
|
||||
except IndexError:
|
||||
LOGGER.warning('could not get fields; returning empty set')
|
||||
return {}
|
||||
|
||||
for k, v in p['properties'].items():
|
||||
if 'type' in v:
|
||||
if v['type'] == 'text':
|
||||
self._fields[k] = {'type': 'string'}
|
||||
elif v['type'] == 'date':
|
||||
self._fields[k] = {'type': 'string', 'format': 'date'}
|
||||
elif v['type'] in ('float', 'long'):
|
||||
self._fields[k] = {'type': 'number',
|
||||
'format': v['type']}
|
||||
else:
|
||||
self._fields[k] = {'type': v['type']}
|
||||
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
bbox=[], datetime_=None, properties=[], sortby=[],
|
||||
select_properties=[], skip_geometry=False, q=None,
|
||||
filterq=None, **kwargs):
|
||||
"""
|
||||
query OpenSearch index
|
||||
|
||||
:param offset: starting record to return (default 0)
|
||||
:param limit: number of records to return (default 10)
|
||||
:param resulttype: return results or hit limit (default results)
|
||||
:param bbox: bounding box [minx,miny,maxx,maxy]
|
||||
:param datetime_: temporal (datestamp or extent)
|
||||
:param properties: list of tuples (name, value)
|
||||
:param sortby: list of dicts (property, order)
|
||||
:param select_properties: list of property names
|
||||
:param skip_geometry: bool of whether to skip geometry (default False)
|
||||
:param q: full-text search term(s)
|
||||
:param filterq: filter object
|
||||
|
||||
:returns: dict of 0..n GeoJSON features
|
||||
"""
|
||||
|
||||
self.select_properties = select_properties
|
||||
|
||||
query = {'track_total_hits': True, 'query': {'bool': {'filter': []}}}
|
||||
filter_ = []
|
||||
|
||||
feature_collection = {
|
||||
'type': 'FeatureCollection',
|
||||
'features': []
|
||||
}
|
||||
|
||||
if resulttype == 'hits':
|
||||
LOGGER.debug('hits only specified')
|
||||
limit = 0
|
||||
|
||||
if bbox:
|
||||
LOGGER.debug('processing bbox parameter')
|
||||
minx, miny, maxx, maxy = bbox
|
||||
bbox_filter = {
|
||||
'geo_shape': {
|
||||
'geometry': {
|
||||
'shape': {
|
||||
'type': 'envelope',
|
||||
'coordinates': [[minx, maxy], [maxx, miny]]
|
||||
},
|
||||
'relation': 'intersects'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query['query']['bool']['filter'].append(bbox_filter)
|
||||
|
||||
if datetime_ is not None:
|
||||
LOGGER.debug('processing datetime parameter')
|
||||
if self.time_field is None:
|
||||
LOGGER.error('time_field not enabled for collection')
|
||||
raise ProviderQueryError()
|
||||
|
||||
time_field = self.mask_prop(self.time_field)
|
||||
|
||||
if '/' in datetime_: # envelope
|
||||
LOGGER.debug('detected time range')
|
||||
time_begin, time_end = datetime_.split('/')
|
||||
|
||||
range_ = {
|
||||
'range': {
|
||||
time_field: {
|
||||
'gte': time_begin,
|
||||
'lte': time_end
|
||||
}
|
||||
}
|
||||
}
|
||||
if time_begin == '..':
|
||||
range_['range'][time_field].pop('gte')
|
||||
elif time_end == '..':
|
||||
range_['range'][time_field].pop('lte')
|
||||
|
||||
filter_.append(range_)
|
||||
|
||||
else: # time instant
|
||||
LOGGER.debug('detected time instant')
|
||||
filter_.append({'match': {time_field: datetime_}})
|
||||
|
||||
LOGGER.debug(filter_)
|
||||
query['query']['bool']['filter'].append(*filter_)
|
||||
|
||||
if properties:
|
||||
LOGGER.debug('processing properties')
|
||||
for prop in properties:
|
||||
prop_name = self.mask_prop(prop[0])
|
||||
pf = {
|
||||
'match': {
|
||||
prop_name: {
|
||||
'query': prop[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
query['query']['bool']['filter'].append(pf)
|
||||
|
||||
if '|' not in prop[1]:
|
||||
pf['match'][prop_name]['minimum_should_match'] = '100%'
|
||||
|
||||
if sortby:
|
||||
LOGGER.debug('processing sortby')
|
||||
query['sort'] = []
|
||||
for sort in sortby:
|
||||
LOGGER.debug(f'processing sort object: {sort}')
|
||||
|
||||
sp = sort['property']
|
||||
|
||||
if (self.fields[sp]['type'] == 'string'
|
||||
and self.fields[sp].get('format') != 'date'):
|
||||
LOGGER.debug('setting OpenSearch .raw on property')
|
||||
sort_property = f'{self.mask_prop(sp)}.raw'
|
||||
else:
|
||||
sort_property = self.mask_prop(sp)
|
||||
|
||||
sort_order = 'asc'
|
||||
if sort['order'] == '-':
|
||||
sort_order = 'desc'
|
||||
|
||||
sort_ = {
|
||||
sort_property: {
|
||||
'order': sort_order
|
||||
}
|
||||
}
|
||||
query['sort'].append(sort_)
|
||||
|
||||
if q is not None:
|
||||
LOGGER.debug('Adding free-text search')
|
||||
query['query']['bool']['must'] = {'query_string': {'query': q}}
|
||||
|
||||
query['_source'] = {
|
||||
'excludes': [
|
||||
'properties._metadata-payload',
|
||||
'properties._metadata-schema',
|
||||
'properties._metadata-format'
|
||||
]
|
||||
}
|
||||
|
||||
if self.properties or self.select_properties:
|
||||
LOGGER.debug('filtering properties')
|
||||
|
||||
all_properties = self.get_properties()
|
||||
|
||||
query['_source'] = {
|
||||
'includes': list(map(self.mask_prop, all_properties))
|
||||
}
|
||||
|
||||
query['_source']['includes'].append('id')
|
||||
query['_source']['includes'].append('type')
|
||||
query['_source']['includes'].append('geometry')
|
||||
|
||||
if skip_geometry:
|
||||
LOGGER.debug('excluding geometry')
|
||||
try:
|
||||
query['_source']['excludes'] = ['geometry']
|
||||
except KeyError:
|
||||
query['_source'] = {'excludes': ['geometry']}
|
||||
try:
|
||||
LOGGER.debug('querying OpenSearch')
|
||||
if filterq:
|
||||
LOGGER.debug(f'adding cql object: {filterq.json()}')
|
||||
query = update_query(input_query=query, cql=filterq)
|
||||
LOGGER.debug(json.dumps(query, indent=4))
|
||||
|
||||
LOGGER.debug('Testing for OpenSearch scrolling')
|
||||
if offset + limit > 10000:
|
||||
gen = helpers.scan(client=self.os_, query=query,
|
||||
preserve_order=True,
|
||||
index=self.index_name)
|
||||
results = {'hits': {'total': limit, 'hits': []}}
|
||||
for i in range(offset + limit):
|
||||
try:
|
||||
if i >= offset:
|
||||
results['hits']['hits'].append(next(gen))
|
||||
else:
|
||||
next(gen)
|
||||
except StopIteration:
|
||||
break
|
||||
|
||||
matched = len(results['hits']['hits']) + offset
|
||||
returned = len(results['hits']['hits'])
|
||||
else:
|
||||
es_results = self.os_.search(index=self.index_name,
|
||||
from_=offset, size=limit,
|
||||
body=query)
|
||||
results = es_results
|
||||
matched = es_results['hits']['total']['value']
|
||||
returned = len(es_results['hits']['hits'])
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
feature_collection['numberMatched'] = matched
|
||||
|
||||
if resulttype == 'hits':
|
||||
return feature_collection
|
||||
|
||||
feature_collection['numberReturned'] = returned
|
||||
|
||||
LOGGER.debug('serializing features')
|
||||
for feature in results['hits']['hits']:
|
||||
feature_ = self.osdoc2geojson(feature)
|
||||
feature_collection['features'].append(feature_)
|
||||
|
||||
return feature_collection
|
||||
|
||||
@crs_transform
|
||||
def get(self, identifier, **kwargs):
|
||||
"""
|
||||
Get OpenSearch document by id
|
||||
|
||||
:param identifier: feature id
|
||||
|
||||
:returns: dict of single GeoJSON feature
|
||||
"""
|
||||
|
||||
try:
|
||||
LOGGER.debug(f'Fetching identifier {identifier}')
|
||||
result = self.os_.get(index=self.index_name, id=identifier)
|
||||
LOGGER.debug('Serializing feature')
|
||||
feature_ = self.osdoc2geojson(result)
|
||||
except Exception as err:
|
||||
LOGGER.debug(f'Not found via OpenSearch id query: {err}')
|
||||
LOGGER.debug('Trying via a real query')
|
||||
|
||||
query = {
|
||||
'query': {
|
||||
'bool': {
|
||||
'filter': [{
|
||||
'match_phrase': {
|
||||
'_id': identifier
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.debug(f'Query: {query}')
|
||||
try:
|
||||
result = self.os_search(index=self.index_name, **query)
|
||||
if len(result['hits']['hits']) == 0:
|
||||
LOGGER.error(err)
|
||||
raise ProviderItemNotFoundError(err)
|
||||
LOGGER.debug('Serializing feature')
|
||||
feature_ = self.osdoc2geojson(result['hits']['hits'][0])
|
||||
except Exception as err2:
|
||||
LOGGER.error(err2)
|
||||
raise ProviderItemNotFoundError(err2)
|
||||
except Exception as err:
|
||||
LOGGER.error(err)
|
||||
return None
|
||||
|
||||
return feature_
|
||||
|
||||
def create(self, item):
|
||||
"""
|
||||
Create a new item
|
||||
|
||||
:param item: `dict` of new item
|
||||
|
||||
:returns: identifier of created item
|
||||
"""
|
||||
|
||||
identifier, json_data = self._load_and_prepare_item(
|
||||
item, accept_missing_identifier=True)
|
||||
if identifier is None:
|
||||
# If there is no incoming identifier, allocate a random one
|
||||
identifier = str(uuid.uuid4())
|
||||
json_data["id"] = identifier
|
||||
|
||||
LOGGER.debug(f'Inserting data with identifier {identifier}')
|
||||
_ = self.os_.index(index=self.index_name, id=identifier,
|
||||
body=json_data)
|
||||
LOGGER.debug('Item added')
|
||||
|
||||
return identifier
|
||||
|
||||
def update(self, identifier, item):
|
||||
"""
|
||||
Updates an existing item
|
||||
|
||||
:param identifier: feature id
|
||||
:param item: `dict` of partial or full item
|
||||
|
||||
:returns: `bool` of update result
|
||||
"""
|
||||
|
||||
LOGGER.debug(f'Updating item {identifier}')
|
||||
identifier, json_data = self._load_and_prepare_item(
|
||||
item, identifier, raise_if_exists=False)
|
||||
|
||||
_ = self.os_index(index=self.index_name, id=identifier, body=json_data)
|
||||
|
||||
return True
|
||||
|
||||
def delete(self, identifier):
|
||||
"""
|
||||
Deletes an existing item
|
||||
|
||||
:param identifier: item id
|
||||
|
||||
:returns: `bool` of deletion result
|
||||
"""
|
||||
|
||||
LOGGER.debug(f'Deleting item {identifier}')
|
||||
_ = self.os_delete(index=self.index_name, id=identifier)
|
||||
|
||||
return True
|
||||
|
||||
def osdoc2geojson(self, doc):
|
||||
"""
|
||||
generate GeoJSON `dict` from OpenSearch document
|
||||
:param doc: `dict` of OpenSearch document
|
||||
|
||||
:returns: GeoJSON `dict`
|
||||
"""
|
||||
|
||||
feature_ = {}
|
||||
feature_thinned = {}
|
||||
|
||||
LOGGER.debug('Fetching id and geometry from GeoJSON document')
|
||||
feature_ = doc['_source']
|
||||
|
||||
try:
|
||||
id_ = doc['_source']['properties'][self.id_field]
|
||||
except KeyError as err:
|
||||
LOGGER.debug(f'Missing field: {err}')
|
||||
id_ = doc['_source'].get('id', doc['_id'])
|
||||
|
||||
feature_['id'] = id_
|
||||
feature_['geometry'] = doc['_source'].get('geometry')
|
||||
|
||||
if self.properties or self.select_properties:
|
||||
LOGGER.debug('Filtering properties')
|
||||
all_properties = self.get_properties()
|
||||
|
||||
feature_thinned = {
|
||||
'id': id_,
|
||||
'type': feature_['type'],
|
||||
'geometry': feature_.get('geometry'),
|
||||
'properties': OrderedDict()
|
||||
}
|
||||
for p in all_properties:
|
||||
try:
|
||||
feature_thinned['properties'][p] = feature_['properties'][p] # noqa
|
||||
except KeyError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderQueryError()
|
||||
|
||||
if feature_thinned:
|
||||
return feature_thinned
|
||||
else:
|
||||
return feature_
|
||||
|
||||
def mask_prop(self, property_name):
|
||||
"""
|
||||
generate property name based on OpenSearch backend setup
|
||||
|
||||
:param property_name: property name
|
||||
|
||||
:returns: masked property name
|
||||
"""
|
||||
|
||||
return f'properties.{property_name}'
|
||||
|
||||
def get_properties(self):
|
||||
all_properties = []
|
||||
|
||||
LOGGER.debug(f'configured properties: {self.properties}')
|
||||
LOGGER.debug(f'selected properties: {self.select_properties}')
|
||||
|
||||
if not self.properties and not self.select_properties:
|
||||
all_properties = self.get_fields()
|
||||
if self.properties and self.select_properties:
|
||||
all_properties = self.properties and self.select_properties
|
||||
else:
|
||||
all_properties = self.properties or self.select_properties
|
||||
|
||||
LOGGER.debug(f'resulting properties: {all_properties}')
|
||||
return all_properties
|
||||
|
||||
def __repr__(self):
|
||||
return f'<OpenSearchProvider> {self.data}'
|
||||
|
||||
|
||||
class OpenSearchCatalogueProvider(OpenSearchProvider):
|
||||
"""OpenSearch Provider"""
|
||||
|
||||
def __init__(self, provider_def):
|
||||
super().__init__(provider_def)
|
||||
|
||||
def _excludes(self):
|
||||
return [
|
||||
'properties._metadata-anytext'
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
fields = super().get_fields()
|
||||
for i in self._excludes():
|
||||
if i in fields:
|
||||
del fields[i]
|
||||
|
||||
fields['q'] = {'type': 'string'}
|
||||
|
||||
return fields
|
||||
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
bbox=[], datetime_=None, properties=[], sortby=[],
|
||||
select_properties=[], skip_geometry=False, q=None,
|
||||
filterq=None, **kwargs):
|
||||
|
||||
records = super().query(
|
||||
offset=offset, limit=limit,
|
||||
resulttype=resulttype, bbox=bbox,
|
||||
datetime_=datetime_, properties=properties,
|
||||
sortby=sortby,
|
||||
select_properties=select_properties,
|
||||
skip_geometry=skip_geometry,
|
||||
q=q)
|
||||
|
||||
return records
|
||||
|
||||
def __repr__(self):
|
||||
return f'<OpenSearchCatalogueProvider> {self.data}'
|
||||
|
||||
|
||||
class OpenSearchQueryBuilder:
|
||||
def __init__(self):
|
||||
self._operation = None
|
||||
self.must_value = {}
|
||||
self.should_value = {}
|
||||
self.mustnot_value = {}
|
||||
self.filter_value = {}
|
||||
|
||||
def must(self, must_value):
|
||||
self.must_value = must_value
|
||||
return self
|
||||
|
||||
def should(self, should_value):
|
||||
self.should_value = should_value
|
||||
return self
|
||||
|
||||
def must_not(self, mustnot_value):
|
||||
self.mustnot_value = mustnot_value
|
||||
return self
|
||||
|
||||
def filter(self, filter_value):
|
||||
self.filter_value = filter_value
|
||||
return self
|
||||
|
||||
@property
|
||||
def operation(self):
|
||||
return self._operation
|
||||
|
||||
@operation.setter
|
||||
def operation(self, value):
|
||||
self._operation = value
|
||||
|
||||
def build(self):
|
||||
if self.must_value:
|
||||
must_clause = self.must_value or {}
|
||||
if self.should_value:
|
||||
should_clause = self.should_value or {}
|
||||
if self.mustnot_value:
|
||||
mustnot_clause = self.mustnot_value or {}
|
||||
if self.filter_value:
|
||||
filter_clause = self.filter_value or {}
|
||||
else:
|
||||
filter_clause = {}
|
||||
|
||||
# to figure out how to deal with logical operations
|
||||
# return match_clause & range_clause
|
||||
clauses = must_clause or should_clause or mustnot_clause
|
||||
filters = filter_clause
|
||||
if self.operation == 'and':
|
||||
res = Q(
|
||||
'bool',
|
||||
must=[clause for clause in clauses],
|
||||
filter=[filter for filter in filters])
|
||||
elif self.operation == 'or':
|
||||
res = Q(
|
||||
'bool',
|
||||
should=[clause for clause in clauses],
|
||||
filter=[filter for filter in filters])
|
||||
elif self.operation == 'not':
|
||||
res = Q(
|
||||
'bool',
|
||||
must_not=[clause for clause in clauses],
|
||||
filter=[filter for filter in filters])
|
||||
else:
|
||||
if filters:
|
||||
res = Q(
|
||||
'bool',
|
||||
must=[clauses],
|
||||
filter=[filters])
|
||||
else:
|
||||
res = Q(
|
||||
'bool',
|
||||
must=[clauses])
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _build_query(q, cql):
|
||||
|
||||
# this would be handled by the AST with the traverse of CQL model
|
||||
op, node = get_next_node(cql.__root__)
|
||||
q.operation = op
|
||||
if isinstance(node, list):
|
||||
query_list = []
|
||||
for elem in node:
|
||||
op, next_node = get_next_node(elem)
|
||||
if not getattr(next_node, 'between', 0) == 0:
|
||||
property = next_node.between.value.__root__.__root__.property
|
||||
lower = next_node.between.lower.__root__.__root__
|
||||
upper = next_node.between.upper.__root__.__root__
|
||||
query_list.append(Q(
|
||||
{
|
||||
'range':
|
||||
{
|
||||
f'{property}': {
|
||||
'gte': lower, 'lte': upper
|
||||
}
|
||||
}
|
||||
}
|
||||
))
|
||||
if not getattr(next_node, '__root__', 0) == 0:
|
||||
scalars = tuple(next_node.__root__.eq.__root__)
|
||||
property = scalars[0].__root__.property
|
||||
value = scalars[1].__root__.__root__
|
||||
query_list.append(Q(
|
||||
{'match': {f'{property}': f'{value}'}}
|
||||
))
|
||||
q.must(query_list)
|
||||
elif not getattr(node, 'between', 0) == 0:
|
||||
property = node.between.value.__root__.__root__.property
|
||||
lower = None
|
||||
if not getattr(node.between.lower,
|
||||
'__root__', 0) == 0:
|
||||
lower = node.between.lower.__root__.__root__
|
||||
upper = None
|
||||
if not getattr(node.between.upper,
|
||||
'__root__', 0) == 0:
|
||||
upper = node.between.upper.__root__.__root__
|
||||
query = Q(
|
||||
{
|
||||
'range':
|
||||
{
|
||||
f'{property}': {
|
||||
'gte': lower, 'lte': upper
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
q.must(query)
|
||||
elif not getattr(node, '__root__', 0) == 0:
|
||||
next_op, next_node = get_next_node(node)
|
||||
if not getattr(next_node, 'eq', 0) == 0:
|
||||
scalars = tuple(next_node.eq.__root__)
|
||||
property = scalars[0].__root__.property
|
||||
value = scalars[1].__root__.__root__
|
||||
query = Q(
|
||||
{'match': {f'{property}': f'{value}'}}
|
||||
)
|
||||
q.must(query)
|
||||
elif not getattr(node, 'intersects', 0) == 0:
|
||||
property = node.intersects.__root__[0].__root__.property
|
||||
if property == 'geometry':
|
||||
geom_type = node.intersects.__root__[
|
||||
1].__root__.__root__.__root__.type
|
||||
if geom_type == 'Polygon':
|
||||
coordinates = node.intersects.__root__[
|
||||
1].__root__.__root__.__root__.coordinates
|
||||
coords_list = [
|
||||
poly_coords.__root__ for poly_coords in coordinates[0]
|
||||
]
|
||||
filter_ = Q(
|
||||
{
|
||||
'geo_shape': {
|
||||
'geometry': {
|
||||
'shape': {
|
||||
'type': 'envelope',
|
||||
'coordinates': get_envelope(
|
||||
coords_list)
|
||||
},
|
||||
'relation': 'intersects'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
query_all = Q(
|
||||
{'match_all': {}}
|
||||
)
|
||||
q.must(query_all)
|
||||
q.filter(filter_)
|
||||
return q.build()
|
||||
|
||||
|
||||
def update_query(input_query: Dict, cql: CQLModel):
|
||||
s = Search.from_dict(input_query)
|
||||
query = OpenSearchQueryBuilder()
|
||||
output_query = _build_query(query, cql)
|
||||
s = s.query(output_query)
|
||||
|
||||
LOGGER.debug(f'Enhanced query: {json.dumps(s.to_dict())}')
|
||||
return s.to_dict()
|
||||
+41
-13
@@ -66,17 +66,31 @@ class DatabaseConnection:
|
||||
"""Initialize the connection pool for the class
|
||||
Lock is implemented before function call at __init__"""
|
||||
dsn = cls._make_dsn(conn_dict)
|
||||
# Create the pool
|
||||
|
||||
p = oracledb.create_pool(
|
||||
user=conn_dict["user"],
|
||||
password=conn_dict["password"],
|
||||
dsn=dsn,
|
||||
min=oracle_pool_min,
|
||||
max=oracle_pool_max,
|
||||
increment=1,
|
||||
)
|
||||
LOGGER.debug("Connection pool created successfully.")
|
||||
connect_kwargs = {
|
||||
'dsn': dsn,
|
||||
'min': oracle_pool_min,
|
||||
'max': oracle_pool_max,
|
||||
'increment': 1
|
||||
}
|
||||
|
||||
# Create the pool
|
||||
if conn_dict.get("external_auth") == "wallet":
|
||||
# If Auth is via Wallet you need to save a wallet under
|
||||
# the directory returned by this bash command if apache is used
|
||||
# cat /etc/passwd |grep apache
|
||||
# except another directory is specified in the sqlnet.ora file
|
||||
LOGGER.debug("Connection pool from wallet.")
|
||||
connect_kwargs["externalauth"] = True
|
||||
connect_kwargs["homogeneous"] = False
|
||||
|
||||
else:
|
||||
LOGGER.debug("Connection pool from user and password.")
|
||||
connect_kwargs["user"] = conn_dict["user"]
|
||||
connect_kwargs["password"] = conn_dict["password"]
|
||||
|
||||
p = oracledb.create_pool(**connect_kwargs)
|
||||
LOGGER.debug("Connection pool created successfully")
|
||||
|
||||
return p
|
||||
|
||||
@@ -435,12 +449,12 @@ class OracleProvider(BaseProvider):
|
||||
"""
|
||||
LOGGER.debug("Get available fields/properties")
|
||||
|
||||
if not self.fields:
|
||||
if not self._fields:
|
||||
with DatabaseConnection(
|
||||
self.conn_dic, self.table, properties=self.properties
|
||||
) as db:
|
||||
self.fields = db.fields
|
||||
return self.fields
|
||||
self._fields = db.fields
|
||||
return self._fields
|
||||
|
||||
def _get_where_clauses(
|
||||
self,
|
||||
@@ -633,6 +647,19 @@ class OracleProvider(BaseProvider):
|
||||
|
||||
:returns: GeoJSON FeaturesCollection
|
||||
"""
|
||||
LOGGER.debug(f"properties contains: {properties}")
|
||||
|
||||
# NOTE: properties contains field keys plus extra params
|
||||
# need to split them up here
|
||||
filtered_properties = []
|
||||
extra_params = {}
|
||||
for (key, value) in properties:
|
||||
if key in self.fields.keys():
|
||||
filtered_properties.append((key, value))
|
||||
else:
|
||||
extra_params[key] = value
|
||||
|
||||
properties = filtered_properties
|
||||
|
||||
# Check mandatory filter properties
|
||||
property_dict = dict(properties)
|
||||
@@ -790,6 +817,7 @@ class OracleProvider(BaseProvider):
|
||||
q,
|
||||
language,
|
||||
filterq,
|
||||
extra_params=extra_params
|
||||
)
|
||||
|
||||
# Clean up placeholders that aren't used by the
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
# =================================================================
|
||||
#
|
||||
# Authors: Leo Ghignone <leo.ghignone@gmail.com>
|
||||
#
|
||||
# Copyright (c) 2024 Leo Ghignone
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person
|
||||
# obtaining a copy of this software and associated documentation
|
||||
# files (the "Software"), to deal in the Software without
|
||||
# restriction, including without limitation the rights to use,
|
||||
# copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the
|
||||
# Software is furnished to do so, subject to the following
|
||||
# conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
# OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
# =================================================================
|
||||
|
||||
from itertools import chain
|
||||
import json
|
||||
import logging
|
||||
|
||||
from dateutil.parser import isoparse
|
||||
import geopandas as gpd
|
||||
import pyarrow
|
||||
import pyarrow.compute as pc
|
||||
import pyarrow.dataset
|
||||
import s3fs
|
||||
|
||||
from pygeoapi.provider.base import (
|
||||
BaseProvider,
|
||||
ProviderConnectionError,
|
||||
ProviderGenericError,
|
||||
ProviderItemNotFoundError,
|
||||
ProviderQueryError,
|
||||
)
|
||||
from pygeoapi.util import crs_transform
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def arrow_to_pandas_type(arrow_type):
|
||||
pd_type = arrow_type.to_pandas_dtype()
|
||||
try:
|
||||
# Needed for specific types such as dtype('<M8[ns]')
|
||||
pd_type = pd_type.type
|
||||
except AttributeError:
|
||||
pd_type = pd_type
|
||||
return pd_type
|
||||
|
||||
|
||||
class ParquetProvider(BaseProvider):
|
||||
def __init__(self, provider_def):
|
||||
"""
|
||||
Initialize object
|
||||
|
||||
# Typical ParquetProvider YAML config:
|
||||
|
||||
provider:
|
||||
name: Parquet
|
||||
data:
|
||||
source: s3://example.com/parquet_directory/
|
||||
|
||||
id_field: gml_id
|
||||
|
||||
|
||||
:param provider_def: provider definition
|
||||
|
||||
:returns: pygeoapi.provider.parquet.ParquetProvider
|
||||
"""
|
||||
|
||||
super().__init__(provider_def)
|
||||
|
||||
# Source url is required
|
||||
self.source = self.data.get('source')
|
||||
if not self.source:
|
||||
msg = "Need explicit 'source' attr " \
|
||||
"in data field of provider config"
|
||||
LOGGER.error(msg)
|
||||
raise Exception(msg)
|
||||
|
||||
# Manage AWS S3 sources
|
||||
if self.source.startswith('s3'):
|
||||
self.source = self.source.split('://', 1)[1]
|
||||
self.fs = s3fs.S3FileSystem(default_cache_type='none')
|
||||
else:
|
||||
self.fs = None
|
||||
|
||||
# Build pyarrow dataset pointing to the data
|
||||
self.ds = pyarrow.dataset.dataset(self.source, filesystem=self.fs)
|
||||
|
||||
LOGGER.debug('Grabbing field information')
|
||||
self.fields = self.get_fields() # Must be set to visualise queryables
|
||||
|
||||
# Column names for bounding box data.
|
||||
if None in [self.x_field, self.y_field]:
|
||||
self.has_geometry = False
|
||||
else:
|
||||
self.has_geometry = True
|
||||
if isinstance(self.x_field, str):
|
||||
self.minx = self.x_field
|
||||
self.maxx = self.x_field
|
||||
else:
|
||||
self.minx, self.maxx = self.x_field
|
||||
|
||||
if isinstance(self.y_field, str):
|
||||
self.miny = self.y_field
|
||||
self.maxy = self.y_field
|
||||
else:
|
||||
self.miny, self.maxy = self.y_field
|
||||
self.bb = [self.minx, self.miny, self.maxx, self.maxy]
|
||||
|
||||
# Get the CRS of the data
|
||||
geo_metadata = json.loads(self.ds.schema.metadata[b'geo'])
|
||||
geom_column = geo_metadata['primary_column']
|
||||
# if the CRS is not set default to EPSG:4326, per geoparquet spec
|
||||
self.crs = (geo_metadata['columns'][geom_column]['crs']
|
||||
or 'EPSG:4326')
|
||||
|
||||
def _read_parquet(self, return_scanner=False, **kwargs):
|
||||
"""
|
||||
Scan a Parquet dataset with the given arguments
|
||||
|
||||
:returns: generator of RecordBatch with the queried values
|
||||
"""
|
||||
scanner = pyarrow.dataset.Scanner.from_dataset(self.ds, **kwargs)
|
||||
batches = scanner.to_batches()
|
||||
if return_scanner:
|
||||
return batches, scanner
|
||||
else:
|
||||
return batches
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
Get provider field information (names, types)
|
||||
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
fields = dict()
|
||||
|
||||
for field_name, field_type in zip(self.ds.schema.names,
|
||||
self.ds.schema.types):
|
||||
# Geometry is managed as a special case by pygeoapi
|
||||
if field_name == 'geometry':
|
||||
continue
|
||||
|
||||
field_type = str(field_type)
|
||||
converted_type = None
|
||||
converted_format = None
|
||||
if field_type.startswith(('int', 'uint')):
|
||||
converted_type = 'integer'
|
||||
converted_format = field_type
|
||||
elif field_type == 'double' or field_type.startswith('float'):
|
||||
converted_type = 'number'
|
||||
converted_format = field_type
|
||||
elif field_type == 'string':
|
||||
converted_type = 'string'
|
||||
elif field_type == 'bool':
|
||||
converted_type = 'boolean'
|
||||
elif field_type.startswith('timestamp'):
|
||||
converted_type = 'string'
|
||||
converted_format = 'date-time'
|
||||
else:
|
||||
LOGGER.error(f'Unsupported field type {field_type}')
|
||||
|
||||
if converted_format is None:
|
||||
fields[field_name] = {'type': converted_type}
|
||||
else:
|
||||
fields[field_name] = {
|
||||
'type': converted_type,
|
||||
'format': converted_format,
|
||||
}
|
||||
|
||||
return fields
|
||||
|
||||
@crs_transform
|
||||
def query(
|
||||
self,
|
||||
offset=0,
|
||||
limit=10,
|
||||
resulttype='results',
|
||||
bbox=[],
|
||||
datetime_=None,
|
||||
properties=[],
|
||||
select_properties=[],
|
||||
skip_geometry=False,
|
||||
q=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Query Parquet source
|
||||
|
||||
:param offset: starting record to return (default 0)
|
||||
:param limit: number of records to return (default 10)
|
||||
:param resulttype: return results or hit limit (default results)
|
||||
:param bbox: bounding box [minx,miny,maxx,maxy]
|
||||
:param datetime_: temporal (datestamp or extent) following ISO-8601
|
||||
:param properties: list of tuples (field, comparison, value)
|
||||
:param select_properties: list of property names
|
||||
:param skip_geometry: bool of whether to skip geometry (default False)
|
||||
|
||||
:returns: dict of 0..n GeoJSON features
|
||||
"""
|
||||
result = None
|
||||
try:
|
||||
filter = pc.scalar(True)
|
||||
if bbox:
|
||||
if self.has_geometry is False:
|
||||
msg = (
|
||||
'Dataset does not have a geometry field, '
|
||||
'querying by bbox is not supported.'
|
||||
)
|
||||
raise ProviderQueryError(msg)
|
||||
LOGGER.debug('processing bbox parameter')
|
||||
if any(b is None for b in bbox):
|
||||
msg = 'Dataset does not support bbox filtering'
|
||||
raise ProviderQueryError(msg)
|
||||
|
||||
minx, miny, maxx, maxy = [float(b) for b in bbox]
|
||||
filter = (
|
||||
(pc.field(self.minx) > pc.scalar(minx))
|
||||
& (pc.field(self.miny) > pc.scalar(miny))
|
||||
& (pc.field(self.maxx) < pc.scalar(maxx))
|
||||
& (pc.field(self.maxy) < pc.scalar(maxy))
|
||||
)
|
||||
|
||||
if datetime_ is not None:
|
||||
if self.time_field is None:
|
||||
msg = (
|
||||
'Dataset does not have a time field, '
|
||||
'querying by datetime is not supported.'
|
||||
)
|
||||
raise ProviderQueryError(msg)
|
||||
timefield = pc.field(self.time_field)
|
||||
if '/' in datetime_:
|
||||
begin, end = datetime_.split('/')
|
||||
if begin != '..':
|
||||
begin = isoparse(begin)
|
||||
filter = filter & (timefield >= begin)
|
||||
if end != '..':
|
||||
end = isoparse(end)
|
||||
filter = filter & (timefield <= end)
|
||||
else:
|
||||
target_time = isoparse(datetime_)
|
||||
filter = filter & (timefield == target_time)
|
||||
|
||||
if properties:
|
||||
LOGGER.debug('processing properties')
|
||||
for name, value in properties:
|
||||
field = self.ds.schema.field(name)
|
||||
pd_type = arrow_to_pandas_type(field.type)
|
||||
expr = pc.field(name) == pc.scalar(pd_type(value))
|
||||
|
||||
filter = filter & expr
|
||||
|
||||
if len(select_properties) == 0:
|
||||
select_properties = self.ds.schema.names
|
||||
else: # Load id and geometry together with any specified columns
|
||||
if self.has_geometry and 'geometry' not in select_properties:
|
||||
select_properties.append('geometry')
|
||||
if self.id_field not in select_properties:
|
||||
select_properties.insert(0, self.id_field)
|
||||
|
||||
if skip_geometry:
|
||||
select_properties.remove('geometry')
|
||||
|
||||
# Make response based on resulttype specified
|
||||
if resulttype == 'hits':
|
||||
LOGGER.debug('hits only specified')
|
||||
result = self._response_feature_hits(filter)
|
||||
elif resulttype == 'results':
|
||||
LOGGER.debug('results specified')
|
||||
result = self._response_feature_collection(
|
||||
filter, offset, limit, columns=select_properties
|
||||
)
|
||||
else:
|
||||
LOGGER.error(f'Invalid resulttype: {resulttype}')
|
||||
|
||||
except RuntimeError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderQueryError(err)
|
||||
except ProviderConnectionError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderConnectionError(err)
|
||||
except Exception as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderGenericError(err)
|
||||
|
||||
return result
|
||||
|
||||
@crs_transform
|
||||
def get(self, identifier, **kwargs):
|
||||
"""
|
||||
Get Feature by id
|
||||
|
||||
:param identifier: feature id
|
||||
|
||||
:returns: a single feature
|
||||
"""
|
||||
result = None
|
||||
try:
|
||||
LOGGER.debug(f'Fetching identifier {identifier}')
|
||||
id_type = arrow_to_pandas_type(
|
||||
self.ds.schema.field(self.id_field).type)
|
||||
batches = self._read_parquet(
|
||||
filter=(
|
||||
pc.field(self.id_field) == pc.scalar(id_type(identifier))
|
||||
)
|
||||
)
|
||||
|
||||
for batch in batches:
|
||||
if batch.num_rows > 0:
|
||||
assert (
|
||||
batch.num_rows == 1
|
||||
), f'Multiple items found with ID {identifier}'
|
||||
row = batch.to_pandas()
|
||||
break
|
||||
else:
|
||||
raise ProviderItemNotFoundError(f'ID {identifier} not found')
|
||||
|
||||
if self.has_geometry:
|
||||
geom = gpd.GeoSeries.from_wkb(row['geometry'], crs=self.crs)
|
||||
else:
|
||||
geom = [None]
|
||||
gdf = gpd.GeoDataFrame(row, geometry=geom)
|
||||
LOGGER.debug('results computed')
|
||||
|
||||
# Grab the collection from geopandas geo_interface
|
||||
result = gdf.__geo_interface__['features'][0]
|
||||
|
||||
except RuntimeError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderQueryError(err)
|
||||
except ProviderConnectionError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderConnectionError(err)
|
||||
except ProviderItemNotFoundError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderItemNotFoundError(err)
|
||||
except Exception as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderGenericError(err)
|
||||
|
||||
return result
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ParquetProvider> {self.data}'
|
||||
|
||||
def _response_feature_collection(self, filter, offset, limit,
|
||||
columns=None):
|
||||
"""
|
||||
Assembles output from query as
|
||||
GeoJSON FeatureCollection structure.
|
||||
|
||||
:returns: GeoJSON FeatureCollection
|
||||
"""
|
||||
|
||||
LOGGER.debug(f'offset:{offset}, limit:{limit}')
|
||||
|
||||
try:
|
||||
batches, scanner = self._read_parquet(
|
||||
filter=filter, columns=columns, return_scanner=True
|
||||
)
|
||||
|
||||
# Discard batches until offset is reached
|
||||
counted = 0
|
||||
for batch in batches:
|
||||
if counted + batch.num_rows > offset:
|
||||
# Slice current batch to start from the requested row
|
||||
batch = batch.slice(offset=offset - counted)
|
||||
# Build a new generator yielding the current batch
|
||||
# and all following ones
|
||||
|
||||
batches = chain([batch], batches)
|
||||
break
|
||||
else:
|
||||
counted += batch.num_rows
|
||||
|
||||
# batches is a generator, it will now be either fully spent
|
||||
# or set to the new generator starting from offset
|
||||
|
||||
# Get the next `limit+1` rows
|
||||
# The extra row is used to check if a "next" link is needed
|
||||
# (when numberMatched > offset + limit)
|
||||
batches_list = []
|
||||
read = 0
|
||||
|
||||
for batch in batches:
|
||||
read += batch.num_rows
|
||||
if read > limit:
|
||||
batches_list.append(batch.slice(0, limit + 1))
|
||||
break
|
||||
else:
|
||||
batches_list.append(batch)
|
||||
|
||||
# Passing schema from scanner in case no rows are returned
|
||||
table = pyarrow.Table.from_batches(
|
||||
batches_list, schema=scanner.projected_schema
|
||||
)
|
||||
|
||||
rp = table.to_pandas()
|
||||
|
||||
number_matched = offset + len(rp)
|
||||
|
||||
# Remove the extra row
|
||||
if len(rp) > limit:
|
||||
rp = rp.iloc[:-1]
|
||||
|
||||
if 'geometry' not in rp.columns:
|
||||
# We need a null geometry column to create a GeoDataFrame
|
||||
rp['geometry'] = None
|
||||
geom = gpd.GeoSeries.from_wkb(rp['geometry'])
|
||||
else:
|
||||
geom = gpd.GeoSeries.from_wkb(rp['geometry'], crs=self.crs)
|
||||
|
||||
gdf = gpd.GeoDataFrame(rp, geometry=geom)
|
||||
LOGGER.debug('results computed')
|
||||
result = gdf.__geo_interface__
|
||||
|
||||
# Add numberMatched to generate "next" link
|
||||
result['numberMatched'] = number_matched
|
||||
|
||||
return result
|
||||
|
||||
except RuntimeError as error:
|
||||
LOGGER.error(error)
|
||||
raise error
|
||||
|
||||
def _response_feature_hits(self, filter):
|
||||
"""
|
||||
Assembles GeoJSON hits from row count
|
||||
|
||||
:returns: GeoJSON FeaturesCollection
|
||||
"""
|
||||
|
||||
try:
|
||||
scanner = pyarrow.dataset.Scanner.from_dataset(self.ds,
|
||||
filter=filter)
|
||||
return {
|
||||
'type': 'FeatureCollection',
|
||||
'numberMatched': scanner.count_rows(),
|
||||
'features': [],
|
||||
}
|
||||
except Exception as error:
|
||||
LOGGER.error(error)
|
||||
raise error
|
||||
@@ -62,7 +62,8 @@ import pyproj
|
||||
import shapely
|
||||
from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, desc
|
||||
from sqlalchemy.engine import URL
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
from sqlalchemy.exc import ConstraintColumnNotFoundError, \
|
||||
InvalidRequestError, OperationalError
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
from sqlalchemy.sql.expression import and_
|
||||
@@ -124,7 +125,7 @@ class PostgreSQLProvider(BaseProvider):
|
||||
)
|
||||
|
||||
LOGGER.debug(f'DB connection: {repr(self._engine.url)}')
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
bbox=[], datetime_=None, properties=[], sortby=[],
|
||||
@@ -204,8 +205,6 @@ class PostgreSQLProvider(BaseProvider):
|
||||
|
||||
LOGGER.debug('Get available fields/properties')
|
||||
|
||||
fields = {}
|
||||
|
||||
# sql-schema only allows these types, so we need to map from sqlalchemy
|
||||
# string, number, integer, object, array, boolean, null,
|
||||
# https://json-schema.org/understanding-json-schema/reference/type.html
|
||||
@@ -248,17 +247,18 @@ class PostgreSQLProvider(BaseProvider):
|
||||
LOGGER.debug('No string format detected')
|
||||
return None
|
||||
|
||||
for column in self.table_model.__table__.columns:
|
||||
LOGGER.debug(f'Testing {column.name}')
|
||||
if column.name == self.geom:
|
||||
continue
|
||||
if not self._fields:
|
||||
for column in self.table_model.__table__.columns:
|
||||
LOGGER.debug(f'Testing {column.name}')
|
||||
if column.name == self.geom:
|
||||
continue
|
||||
|
||||
fields[str(column.name)] = {
|
||||
'type': _column_type_to_json_schema_type(column.type),
|
||||
'format': _column_format_to_json_schema_format(column.type)
|
||||
}
|
||||
self._fields[str(column.name)] = {
|
||||
'type': _column_type_to_json_schema_type(column.type),
|
||||
'format': _column_format_to_json_schema_format(column.type)
|
||||
}
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def get(self, identifier, crs_transform_spec=None, **kwargs):
|
||||
"""
|
||||
@@ -516,7 +516,7 @@ def get_table_model(
|
||||
sqlalchemy_table_def = metadata.tables[f'{schema}.{table_name}']
|
||||
try:
|
||||
sqlalchemy_table_def.append_constraint(PrimaryKeyConstraint(id_field))
|
||||
except KeyError:
|
||||
except (ConstraintColumnNotFoundError, KeyError):
|
||||
raise ProviderQueryError(
|
||||
f"No such id_field column ({id_field}) on {schema}.{table_name}.")
|
||||
|
||||
|
||||
@@ -59,38 +59,39 @@ class RasterioProvider(BaseProvider):
|
||||
self.axes = self._coverage_properties['axes']
|
||||
self.crs = self._coverage_properties['bbox_crs']
|
||||
self.num_bands = self._coverage_properties['num_bands']
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
self.native_format = provider_def['format']['name']
|
||||
except Exception as err:
|
||||
LOGGER.warning(err)
|
||||
raise ProviderConnectionError(err)
|
||||
|
||||
def get_fields(self):
|
||||
fields = {}
|
||||
if not self._fields:
|
||||
for i, dtype in zip(self._data.indexes, self._data.dtypes):
|
||||
LOGGER.debug(f'Adding field for band {i}')
|
||||
i2 = str(i)
|
||||
|
||||
for i, dtype in zip(self._data.indexes, self._data.dtypes):
|
||||
LOGGER.debug(f'Adding field for band {i}')
|
||||
i2 = str(i)
|
||||
parameter = _get_parameter_metadata(
|
||||
self._data.profile['driver'], self._data.tags(i))
|
||||
|
||||
parameter = _get_parameter_metadata(
|
||||
self._data.profile['driver'], self._data.tags(i))
|
||||
name = parameter['description']
|
||||
units = parameter.get('unit_label')
|
||||
|
||||
name = parameter['description']
|
||||
units = parameter.get('unit_label')
|
||||
dtype2 = dtype
|
||||
if dtype.startswith('float'):
|
||||
dtype2 = 'number'
|
||||
elif dtype.startswith('int'):
|
||||
dtype2 = 'integer'
|
||||
|
||||
dtype2 = dtype
|
||||
if dtype.startswith('float'):
|
||||
dtype2 = 'number'
|
||||
self._fields[i2] = {
|
||||
'title': name,
|
||||
'type': dtype2,
|
||||
'_meta': self._data.tags(i)
|
||||
}
|
||||
if units is not None:
|
||||
self._fields[i2]['x-ogc-unit'] = units
|
||||
|
||||
fields[i2] = {
|
||||
'title': name,
|
||||
'type': dtype2,
|
||||
'_meta': self._data.tags(i)
|
||||
}
|
||||
if units is not None:
|
||||
fields[i2]['x-ogc-unit'] = units
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def query(self, properties=[], subsets={}, bbox=None, bbox_crs=4326,
|
||||
datetime_=None, format_='json', **kwargs):
|
||||
@@ -241,16 +242,15 @@ class RasterioProvider(BaseProvider):
|
||||
out_meta['units'] = _data.units
|
||||
|
||||
LOGGER.debug('Serializing data in memory')
|
||||
with MemoryFile() as memfile:
|
||||
with memfile.open(**out_meta) as dest:
|
||||
dest.write(out_image)
|
||||
if format_ == 'json':
|
||||
LOGGER.debug('Creating output in CoverageJSON')
|
||||
out_meta['bands'] = args['indexes']
|
||||
return self.gen_covjson(out_meta, out_image)
|
||||
|
||||
if format_ == 'json':
|
||||
LOGGER.debug('Creating output in CoverageJSON')
|
||||
out_meta['bands'] = args['indexes']
|
||||
return self.gen_covjson(out_meta, out_image)
|
||||
|
||||
else: # return data in native format
|
||||
else: # return data in native format
|
||||
with MemoryFile() as memfile:
|
||||
with memfile.open(**out_meta) as dest:
|
||||
dest.write(out_image)
|
||||
LOGGER.debug('Returning data in native format')
|
||||
return memfile.read()
|
||||
|
||||
|
||||
+147
-112
@@ -30,14 +30,14 @@
|
||||
# =================================================================
|
||||
|
||||
from json.decoder import JSONDecodeError
|
||||
import os
|
||||
import logging
|
||||
from requests import Session
|
||||
|
||||
from pygeoapi.config import get_config
|
||||
from pygeoapi.provider.base import (
|
||||
BaseProvider, ProviderQueryError, ProviderConnectionError)
|
||||
from pygeoapi.util import (
|
||||
yaml_load, url_join, get_provider_default, crs_transform, get_base_url)
|
||||
url_join, get_provider_default, crs_transform, get_base_url)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,10 +51,10 @@ ENTITY = {
|
||||
_EXPAND = {
|
||||
'Things': 'Locations,Datastreams',
|
||||
'Observations': 'Datastream,FeatureOfInterest',
|
||||
'ObservedProperties': 'Datastreams/Thing/Locations',
|
||||
'Datastreams': """
|
||||
Sensor
|
||||
,ObservedProperty
|
||||
,Thing
|
||||
,Thing/Locations
|
||||
,Observations(
|
||||
$select=@iot.id;
|
||||
@@ -71,6 +71,7 @@ EXPAND = {k: ''.join(v.split()).replace('_', ' ')
|
||||
|
||||
class SensorThingsProvider(BaseProvider):
|
||||
"""SensorThings API (STA) Provider"""
|
||||
expand = EXPAND
|
||||
|
||||
def __init__(self, provider_def):
|
||||
"""
|
||||
@@ -82,64 +83,12 @@ class SensorThingsProvider(BaseProvider):
|
||||
:returns: pygeoapi.provider.sensorthings.SensorThingsProvider
|
||||
"""
|
||||
LOGGER.debug('Setting SensorThings API (STA) provider')
|
||||
|
||||
self.linked_entity = {}
|
||||
super().__init__(provider_def)
|
||||
self.data.rstrip('/')
|
||||
try:
|
||||
self.entity = provider_def['entity']
|
||||
self._url = url_join(self.data, self.entity)
|
||||
except KeyError:
|
||||
LOGGER.debug('Attempting to parse Entity from provider data')
|
||||
if not self._get_entity(self.data):
|
||||
raise RuntimeError('Entity type required')
|
||||
self.entity = self._get_entity(self.data)
|
||||
self._url = self.data
|
||||
self.data = self._url.rstrip(f'/{self.entity}')
|
||||
|
||||
self._generate_mappings(provider_def)
|
||||
LOGGER.debug(f'STA endpoint: {self.data}, Entity: {self.entity}')
|
||||
|
||||
# Default id
|
||||
if self.id_field:
|
||||
LOGGER.debug(f'Using id field: {self.id_field}')
|
||||
else:
|
||||
LOGGER.debug('Using default @iot.id for id field')
|
||||
self.id_field = '@iot.id'
|
||||
|
||||
# Create intra-links
|
||||
self.links = {}
|
||||
self.intralink = provider_def.get('intralink', False)
|
||||
if self.intralink and provider_def.get('rel_link'):
|
||||
# For pytest
|
||||
self.rel_link = provider_def['rel_link']
|
||||
|
||||
elif self.intralink:
|
||||
# Read from pygeoapi config
|
||||
with open(os.getenv('PYGEOAPI_CONFIG'), encoding='utf8') as fh:
|
||||
CONFIG = yaml_load(fh)
|
||||
self.rel_link = get_base_url(CONFIG)
|
||||
|
||||
for (name, rs) in CONFIG['resources'].items():
|
||||
pvs = rs.get('providers')
|
||||
p = get_provider_default(pvs)
|
||||
e = p.get('entity') or self._get_entity(p['data'])
|
||||
if any([
|
||||
not pvs, # No providers in resource
|
||||
not p.get('intralink'), # No configuration for intralinks
|
||||
not e, # No STA entity found
|
||||
self.data not in p.get('data') # No common STA endpoint
|
||||
]):
|
||||
continue
|
||||
|
||||
if p.get('uri_field'):
|
||||
LOGGER.debug(f'Linking {e} with field: {p["uri_field"]}')
|
||||
else:
|
||||
LOGGER.debug(f'Linking {e} with collection: {name}')
|
||||
|
||||
self.links[e] = {
|
||||
'cnm': name, # OAPI collection name,
|
||||
'cid': p.get('id_field', '@iot.id'), # OAPI id_field
|
||||
'uri': p.get('uri_field') # STA uri_field
|
||||
}
|
||||
|
||||
# Start session
|
||||
self.http = Session()
|
||||
self.get_fields()
|
||||
@@ -150,7 +99,7 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
:returns: dict of fields
|
||||
"""
|
||||
if not self.fields:
|
||||
if not self._fields:
|
||||
r = self._get_response(self._url, {'$top': 1})
|
||||
try:
|
||||
results = r['value'][0]
|
||||
@@ -161,11 +110,11 @@ class SensorThingsProvider(BaseProvider):
|
||||
for (n, v) in results.items():
|
||||
if isinstance(v, (int, float)) or \
|
||||
(isinstance(v, (dict, list)) and n in ENTITY):
|
||||
self.fields[n] = {'type': 'number'}
|
||||
self._fields[n] = {'type': 'number'}
|
||||
elif isinstance(v, str):
|
||||
self.fields[n] = {'type': 'string'}
|
||||
self._fields[n] = {'type': 'string'}
|
||||
|
||||
return self.fields
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
@@ -272,17 +221,19 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
return fc
|
||||
|
||||
def _make_feature(self, entity, select_properties=[], skip_geometry=False):
|
||||
def _make_feature(self, feature, select_properties=[], skip_geometry=False,
|
||||
entity=None):
|
||||
"""
|
||||
Private function: Create feature from entity
|
||||
|
||||
:param entity: `dict` of STA entity
|
||||
:param feature: `dict` of STA entity
|
||||
:param select_properties: list of property names
|
||||
:param skip_geometry: bool of whether to skip geometry (default False)
|
||||
:param entity: SensorThings entity name
|
||||
|
||||
:returns: dict of GeoJSON Feature
|
||||
"""
|
||||
_ = entity.pop(self.id_field)
|
||||
_ = feature.pop(self.id_field)
|
||||
id = f"'{_}'" if isinstance(_, str) else str(_)
|
||||
f = {
|
||||
'type': 'Feature', 'id': id, 'properties': {}, 'geometry': None
|
||||
@@ -290,28 +241,35 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
# Make geometry
|
||||
if not skip_geometry:
|
||||
f['geometry'] = self._geometry(entity)
|
||||
f['geometry'] = self._geometry(feature, entity)
|
||||
|
||||
# Fill properties block
|
||||
try:
|
||||
f['properties'] = self._expand_properties(
|
||||
entity, select_properties)
|
||||
feature, select_properties, entity)
|
||||
except KeyError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderQueryError(err)
|
||||
|
||||
return f
|
||||
|
||||
def _get_response(self, url, params={}):
|
||||
def _get_response(self, url, params={}, entity=None, expand=None):
|
||||
"""
|
||||
Private function: Get STA response
|
||||
|
||||
:param url: request url
|
||||
:param params: query parameters
|
||||
:param entity: SensorThings entity name
|
||||
:param expand: SensorThings expand query
|
||||
|
||||
|
||||
:returns: STA response
|
||||
"""
|
||||
params.update({'$expand': EXPAND[self.entity]})
|
||||
if expand:
|
||||
params.update({'$expand': expand})
|
||||
else:
|
||||
entity_ = entity or self.entity
|
||||
params.update({'$expand': self.expand[entity_]})
|
||||
|
||||
r = self.http.get(url, params=params)
|
||||
|
||||
@@ -327,13 +285,15 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
return response
|
||||
|
||||
def _make_filter(self, properties, bbox=[], datetime_=None):
|
||||
def _make_filter(self, properties, bbox=[], datetime_=None,
|
||||
entity=None):
|
||||
"""
|
||||
Private function: Make STA filter from query properties
|
||||
|
||||
:param properties: list of tuples (name, value)
|
||||
:param bbox: bounding box [minx,miny,maxx,maxy]
|
||||
:param datetime_: temporal (datestamp or extent)
|
||||
:param entity: SensorThings entity name
|
||||
|
||||
:returns: STA $filter string of properties
|
||||
"""
|
||||
@@ -345,16 +305,8 @@ class SensorThingsProvider(BaseProvider):
|
||||
ret.append(f'{name} eq {value}')
|
||||
|
||||
if bbox:
|
||||
minx, miny, maxx, maxy = bbox
|
||||
bbox_ = f'POLYGON (({minx} {miny}, {maxx} {miny}, \
|
||||
{maxx} {maxy}, {minx} {maxy}, {minx} {miny}))'
|
||||
if self.entity == 'Things':
|
||||
loc = 'Locations/location'
|
||||
elif self.entity == 'Datastreams':
|
||||
loc = 'Thing/Locations/location'
|
||||
elif self.entity == 'Observations':
|
||||
loc = 'FeatureOfInterest/feature'
|
||||
ret.append(f"st_within({loc}, geography'{bbox_}')")
|
||||
entity_ = entity or self.entity
|
||||
ret.append(self._make_bbox(bbox, entity_))
|
||||
|
||||
if datetime_ is not None:
|
||||
if self.time_field is None:
|
||||
@@ -373,6 +325,20 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
return ' and '.join(ret)
|
||||
|
||||
@staticmethod
|
||||
def _make_bbox(bbox, entity):
|
||||
minx, miny, maxx, maxy = bbox
|
||||
bbox_ = f'POLYGON(({minx} {miny},{maxx} {miny},{maxx} {maxy},{minx} {maxy},{minx} {miny}))' # noqa
|
||||
if entity == 'Things':
|
||||
loc = 'Locations/location'
|
||||
elif entity == 'Datastreams':
|
||||
loc = 'Thing/Locations/location'
|
||||
elif entity == 'Observations':
|
||||
loc = 'FeatureOfInterest/feature'
|
||||
elif entity == 'ObservedProperties':
|
||||
loc = 'Datastreams/observedArea'
|
||||
return f"st_within({loc},geography'{bbox_}')"
|
||||
|
||||
def _make_orderby(self, sortby):
|
||||
"""
|
||||
Private function: Make STA filter from query properties
|
||||
@@ -393,79 +359,85 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
return ','.join(ret)
|
||||
|
||||
def _geometry(self, entity):
|
||||
def _geometry(self, feature, entity=None):
|
||||
"""
|
||||
Private function: Retrieve STA geometry
|
||||
|
||||
:param entity: SensorThings entity
|
||||
:param feature: SensorThings entity
|
||||
:param entity: SensorThings entity name
|
||||
|
||||
:returns: GeoJSON Geometry for feature
|
||||
"""
|
||||
entity_ = entity or self.entity
|
||||
try:
|
||||
if self.entity == 'Things':
|
||||
return entity['Locations'][0]['location']
|
||||
if entity_ == 'Things':
|
||||
return feature['Locations'][0]['location']
|
||||
|
||||
elif self.entity == 'Observations':
|
||||
return entity['FeatureOfInterest'].pop('feature')
|
||||
elif entity_ == 'Observations':
|
||||
return feature['FeatureOfInterest'].pop('feature')
|
||||
|
||||
elif self.entity == 'Datastreams':
|
||||
elif entity_ == 'Datastreams':
|
||||
try:
|
||||
return entity['Observations'][0]['FeatureOfInterest'].pop('feature') # noqa
|
||||
return feature['Observations'][0]['FeatureOfInterest'].pop('feature') # noqa
|
||||
except (KeyError, IndexError):
|
||||
return entity['Thing'].pop('Locations')[0]['location']
|
||||
return feature['Thing'].pop('Locations')[0]['location']
|
||||
|
||||
elif entity_ == 'ObservedProperties':
|
||||
return feature['Datastreams'][0]['Thing']['Locations'][0]['location'] # noqa
|
||||
|
||||
except (KeyError, IndexError):
|
||||
LOGGER.warning('No geometry found')
|
||||
return None
|
||||
|
||||
def _expand_properties(self, entity, keys=(), uri=''):
|
||||
def _expand_properties(self, feature, keys=(), uri='',
|
||||
entity=None):
|
||||
"""
|
||||
Private function: Parse STA entity into feature
|
||||
|
||||
:param entity: SensorThings entity
|
||||
:param feature: `dict` of SensorThings entity
|
||||
:param keys: keys used in properties block
|
||||
:param uri: uri of STA entity
|
||||
:param entity: SensorThings entity name
|
||||
|
||||
:returns: dict of SensorThings feature properties
|
||||
"""
|
||||
LOGGER.debug('Adding extra properties')
|
||||
|
||||
# Properties filter & display
|
||||
keys = (() if not self.properties and not keys else
|
||||
set(self.properties) | set(keys))
|
||||
|
||||
if self.entity == 'Things':
|
||||
self._expand_location(entity)
|
||||
elif 'Thing' in entity.keys():
|
||||
self._expand_location(entity['Thing'])
|
||||
entity = entity or self.entity
|
||||
if entity == 'Things':
|
||||
self._expand_location(feature)
|
||||
elif 'Thing' in feature.keys():
|
||||
self._expand_location(feature['Thing'])
|
||||
|
||||
# Retain URI if present
|
||||
if entity.get('properties') and self.uri_field:
|
||||
uri = entity['properties']
|
||||
if feature.get('properties') and self.uri_field:
|
||||
uri = feature['properties']
|
||||
|
||||
# Create intra links
|
||||
LOGGER.debug('Creating intralinks')
|
||||
for k, v in entity.items():
|
||||
if k in self.links:
|
||||
entity[k] = [self._get_uri(_v, **self.links[k]) for _v in v]
|
||||
for k, v in feature.items():
|
||||
if k in self.linked_entity:
|
||||
feature[k] = [self._get_uri(_v, **self.linked_entity[k])
|
||||
for _v in v]
|
||||
LOGGER.debug(f'Created link for {k}')
|
||||
elif f'{k}s' in self.links:
|
||||
entity[k] = self._get_uri(v, **self.links[f'{k}s'])
|
||||
elif f'{k}s' in self.linked_entity:
|
||||
feature[k] = \
|
||||
self._get_uri(v, **self.linked_entity[f'{k}s'])
|
||||
LOGGER.debug(f'Created link for {k}')
|
||||
|
||||
# Make properties block
|
||||
LOGGER.debug('Making properties block')
|
||||
if entity.get('properties'):
|
||||
entity.update(entity.pop('properties'))
|
||||
if feature.get('properties'):
|
||||
feature.update(feature.pop('properties'))
|
||||
|
||||
if keys:
|
||||
ret = {k: entity.pop(k) for k in keys}
|
||||
entity = ret
|
||||
ret = {k: feature.pop(k) for k in keys}
|
||||
feature = ret
|
||||
|
||||
if self.uri_field is not None and uri != '':
|
||||
entity[self.uri_field] = uri
|
||||
feature[self.uri_field] = uri
|
||||
|
||||
return entity
|
||||
return feature
|
||||
|
||||
@staticmethod
|
||||
def _expand_location(entity):
|
||||
@@ -517,5 +489,68 @@ class SensorThingsProvider(BaseProvider):
|
||||
else:
|
||||
return ''
|
||||
|
||||
def _generate_mappings(self, provider_def: dict):
|
||||
"""
|
||||
Generate mappings for the STA entity and set up intra-links.
|
||||
|
||||
This function sets up the necessary mappings and configurations for
|
||||
the STA entity based on the provided provider definition.
|
||||
|
||||
:param provider_def: `dict` of provider definition containing
|
||||
configuration details for the STA entity.
|
||||
"""
|
||||
self.data.rstrip('/')
|
||||
try:
|
||||
self.entity = provider_def['entity']
|
||||
self._url = url_join(self.data, self.entity)
|
||||
except KeyError:
|
||||
LOGGER.debug('Attempting to parse Entity from provider data')
|
||||
if not self._get_entity(self.data):
|
||||
raise RuntimeError('Entity type required')
|
||||
self.entity = self._get_entity(self.data)
|
||||
self._url = self.data
|
||||
self.data = self._url.rstrip(f'/{self.entity}')
|
||||
|
||||
# Default id
|
||||
if self.id_field:
|
||||
LOGGER.debug(f'Using id field: {self.id_field}')
|
||||
else:
|
||||
LOGGER.debug('Using default @iot.id for id field')
|
||||
self.id_field = '@iot.id'
|
||||
|
||||
# Create intra-links
|
||||
self.intralink = provider_def.get('intralink', False)
|
||||
if self.intralink and provider_def.get('rel_link'):
|
||||
# For pytest
|
||||
self.rel_link = provider_def['rel_link']
|
||||
|
||||
elif self.intralink:
|
||||
# Read from pygeoapi config
|
||||
CONFIG = get_config()
|
||||
self.rel_link = get_base_url(CONFIG)
|
||||
|
||||
for name, rs in CONFIG['resources'].items():
|
||||
pvs = rs.get('providers')
|
||||
p = get_provider_default(pvs)
|
||||
e = p.get('entity') or self._get_entity(p['data'])
|
||||
if any([
|
||||
not pvs, # No providers in resource
|
||||
not p.get('intralink'), # No configuration for intralinks
|
||||
not e, # No STA entity found
|
||||
self.data not in p.get('data') # No common STA endpoint
|
||||
]):
|
||||
continue
|
||||
|
||||
if p.get('uri_field'):
|
||||
LOGGER.debug(f'Linking {e} with field: {p["uri_field"]}')
|
||||
else:
|
||||
LOGGER.debug(f'Linking {e} with collection: {name}')
|
||||
|
||||
self.linked_entity[e] = {
|
||||
'cnm': name, # OAPI collection name,
|
||||
'cid': p.get('id_field', '@iot.id'), # OAPI id_field
|
||||
'uri': p.get('uri_field') # STA uri_field
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SensorThingsProvider> {self.data}, {self.entity}'
|
||||
|
||||
@@ -75,7 +75,7 @@ class SODAServiceProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
if not self.fields:
|
||||
if not self._fields:
|
||||
|
||||
try:
|
||||
[dataset] = self.client.datasets(ids=[self.resource_id])
|
||||
@@ -87,9 +87,9 @@ class SODAServiceProvider(BaseProvider):
|
||||
fields = self.properties or resource[FIELD_NAME]
|
||||
for field in fields:
|
||||
idx = resource[FIELD_NAME].index(field)
|
||||
self.fields[field] = {'type': resource[DATA_TYPE][idx]}
|
||||
self._fields[field] = {'type': resource[DATA_TYPE][idx]}
|
||||
|
||||
return self.fields
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
# =================================================================
|
||||
#
|
||||
# Authors: Matthew Perry <perrygeo@gmail.com>
|
||||
#
|
||||
# Copyright (c) 2018 Matthew Perry
|
||||
# Copyright (c) 2022 Tom Kralidis
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person
|
||||
# obtaining a copy of this software and associated documentation
|
||||
# files (the "Software"), to deal in the Software without
|
||||
# restriction, including without limitation the rights to use,
|
||||
# copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the
|
||||
# Software is furnished to do so, subject to the following
|
||||
# conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
# OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
# =================================================================
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
import uuid
|
||||
|
||||
from pygeoapi.provider.base import BaseProvider, ProviderItemNotFoundError
|
||||
from pygeoapi.util import crs_transform
|
||||
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
HOST_APP = "pygeoapi"
|
||||
|
||||
class SpeckleProvider(BaseProvider):
|
||||
"""Provider class for Speckle server data
|
||||
This is meant to be simple
|
||||
(no external services, no dependencies, no schema)
|
||||
at the expense of performance
|
||||
(no indexing, full serialization roundtrip on each request)
|
||||
Not thread safe, a single server process is assumed
|
||||
This implementation uses the feature 'id' heavily
|
||||
and will override any 'id' provided in the original data.
|
||||
The feature 'properties' will be preserved.
|
||||
TODO:
|
||||
* query method should take bbox
|
||||
* instead of methods returning FeatureCollections,
|
||||
we should be yielding Features and aggregating in the view
|
||||
* there are strict id semantics; all features in the input GeoJSON file
|
||||
must be present and be unique strings. Otherwise it will break.
|
||||
* How to raise errors in the provider implementation such that
|
||||
* appropriate HTTP responses will be raised
|
||||
"""
|
||||
|
||||
def __init__(self, provider_def):
|
||||
"""initializer"""
|
||||
|
||||
super().__init__(provider_def)
|
||||
|
||||
if self.data is None:
|
||||
self.data = ""
|
||||
# raise ValueError(
|
||||
# "Please provide Speckle project link as an argument, e.g.: 'http://localhost:5000/?limit=100000&https://app.speckle.systems/projects/55a29f3e9d/models/2d497a381d'"
|
||||
# )
|
||||
|
||||
from subprocess import run
|
||||
from pygeoapi.provider.speckle_utils.patch.patch_specklepy import patch_specklepy
|
||||
|
||||
try:
|
||||
import specklepy
|
||||
|
||||
except ModuleNotFoundError:
|
||||
|
||||
completed_process = run(
|
||||
[
|
||||
self.get_python_path(),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
"specklepy==2.19.6",
|
||||
],
|
||||
capture_output=True,
|
||||
)
|
||||
completed_process = run(
|
||||
[
|
||||
self.get_python_path(),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"pydantic==1.10.17",
|
||||
],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if completed_process.returncode != 0:
|
||||
m = f"Failed to install dependenices through pip, got {completed_process.returncode} as return code. Full log: {completed_process}"
|
||||
print(m)
|
||||
print(completed_process.stdout)
|
||||
print(completed_process.stderr)
|
||||
raise Exception(m)
|
||||
|
||||
patch_specklepy()
|
||||
|
||||
|
||||
# assign global values
|
||||
self.url: str = self.data # to store the value and check if self.data has changed
|
||||
self.speckle_url = self.url.lower().split("speckleurl=")[-1].split("&")[0].split("@")[0].split("?")[0]
|
||||
|
||||
self.speckle_data = None
|
||||
self.project_name = ""
|
||||
self.project_id = ""
|
||||
self.model_name = ""
|
||||
self.sourceApp = ""
|
||||
|
||||
self.crs = None
|
||||
self.crs_dict = None
|
||||
|
||||
self.commit_gis = False
|
||||
self.url_params = {"url_data_type":"", "url_preserve_attributes":"", "url_crs_authid":"", "url_lat":"","url_lon":"","url_north_degrees":"","url_limit":""}
|
||||
self.times = {}
|
||||
self.country_code = ""
|
||||
|
||||
self.requested_data_type: str = "polygons (default)" # points, lines, polygons, projectcomments
|
||||
self.preserve_attributes: str = "true (default)"
|
||||
self.lat: float = 48.76755913928929 #51.52486388756923
|
||||
self.lon: float = 11.408741923664028 #0.1621445437168942
|
||||
self.north_degrees: float = 0
|
||||
self.crs_authid = ""
|
||||
self.limit = 10000
|
||||
self.user_agent = ""
|
||||
|
||||
self.missing_url = ""
|
||||
self.limit_message = ""
|
||||
|
||||
self.extent = [-180,-90,180,90]
|
||||
self.extent3d = [-180,-90,0,180,90,1000]
|
||||
self.material_color_proxies = {}
|
||||
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
Get provider field information (names, types)
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
fields = {}
|
||||
LOGGER.debug("Treating all columns as string types")
|
||||
|
||||
if self.speckle_data is None:
|
||||
self._load()
|
||||
|
||||
# check if the object was extracted
|
||||
if isinstance(self.speckle_data, Dict):
|
||||
if len(self.speckle_data["features"]) == 0:
|
||||
return fields
|
||||
|
||||
for key, value in self.speckle_data["features"][0]["properties"].items():
|
||||
if isinstance(value, float):
|
||||
type_ = "number"
|
||||
elif isinstance(value, int):
|
||||
type_ = "integer"
|
||||
else:
|
||||
type_ = "string"
|
||||
|
||||
fields[key] = {"type": type_}
|
||||
return fields
|
||||
|
||||
def _load(self, skip_geometry=None, properties=[], select_properties=[]):
|
||||
"""Load and validate Speckle data"""
|
||||
|
||||
from pygeoapi.provider.speckle_utils.url_utils import get_set_url_parameters
|
||||
|
||||
if self.data == "":
|
||||
return
|
||||
|
||||
get_set_url_parameters(self) # possible ValueError
|
||||
|
||||
# check if it's a new request (self.data was updated and doesn't match self.url)
|
||||
new_request = False
|
||||
if self.url != self.data:
|
||||
new_request = True
|
||||
self.url = self.data
|
||||
|
||||
# check if self.data was updated OR if features were not created yet
|
||||
if (
|
||||
new_request is True
|
||||
or self.speckle_data is None
|
||||
or (
|
||||
isinstance(self.speckle_data, dict)
|
||||
and hasattr(self.speckle_data, "features")
|
||||
and len(self.speckle_data["features"]) > 0
|
||||
and not hasattr(self.speckle_data["features"][0], "properties")
|
||||
)
|
||||
):
|
||||
self.speckle_data = self.load_speckle_data()
|
||||
self.fields = self.get_fields()
|
||||
|
||||
# filter by properties if set
|
||||
if properties:
|
||||
self.speckle_data["features"] = [
|
||||
f
|
||||
for f in self.speckle_data["features"]
|
||||
if all([str(f["properties"][p[0]]) == str(p[1]) for p in properties])
|
||||
] # noqa
|
||||
|
||||
# All features must have ids, TODO must be unique strings
|
||||
if isinstance(self.speckle_data, str):
|
||||
raise Exception(self.speckle_data)
|
||||
for i in self.speckle_data["features"]:
|
||||
# for some reason dictionary is changed to list of links
|
||||
try:
|
||||
i["properties"]
|
||||
except:
|
||||
self.speckle_data = None
|
||||
return self._load()
|
||||
|
||||
if "id" not in i and self.id_field in i["properties"]:
|
||||
i["id"] = i["properties"][self.id_field]
|
||||
if skip_geometry:
|
||||
i["geometry"] = None
|
||||
if self.properties or select_properties:
|
||||
i["properties"] = {
|
||||
k: v
|
||||
for k, v in i["properties"].items()
|
||||
if k in set(self.properties) | set(select_properties)
|
||||
} # noqa
|
||||
|
||||
return self.speckle_data
|
||||
|
||||
@crs_transform
|
||||
def query(
|
||||
self,
|
||||
offset=0,
|
||||
limit=10,
|
||||
resulttype="results",
|
||||
bbox=[],
|
||||
datetime_=None,
|
||||
properties=[],
|
||||
sortby=[],
|
||||
select_properties=[],
|
||||
skip_geometry=False,
|
||||
q=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
query the provider
|
||||
:param offset: starting record to return (default 0)
|
||||
:param limit: number of records to return (default 10)
|
||||
:param resulttype: return results or hit limit (default results)
|
||||
:param bbox: bounding box [minx,miny,maxx,maxy]
|
||||
:param datetime_: temporal (datestamp or extent)
|
||||
:param properties: list of tuples (name, value)
|
||||
:param sortby: list of dicts (property, order)
|
||||
:param select_properties: list of property names
|
||||
:param skip_geometry: bool of whether to skip geometry (default False)
|
||||
:param q: full-text search term(s)
|
||||
:returns: FeatureCollection dict of 0..n GeoJSON features
|
||||
"""
|
||||
|
||||
# TODO filter by bbox without resorting to third-party libs
|
||||
data = self._load(
|
||||
skip_geometry=skip_geometry,
|
||||
properties=properties,
|
||||
select_properties=select_properties,
|
||||
)
|
||||
if data is None:
|
||||
return {"features":[], "comments":[], "extent": [-180,-90,180,90]}
|
||||
|
||||
# add URL parameters
|
||||
data['speckle_url'] = self.speckle_url
|
||||
data['requested_data_type'] = self.requested_data_type
|
||||
data['preserve_attributes'] = self.preserve_attributes
|
||||
data['crs_authid'] = self.crs_authid
|
||||
data['lat'] = self.lat
|
||||
data['lon'] = self.lon
|
||||
data['north_degrees'] = self.north_degrees
|
||||
data['limit'] = self.limit
|
||||
data['missing_url'] = self.missing_url
|
||||
|
||||
|
||||
data["numberMatched"] = len(data["features"])
|
||||
|
||||
if resulttype == "hits":
|
||||
data["features"] = []
|
||||
data["comments"] = []
|
||||
data["extent"] = [-180,-90,180,90]
|
||||
else:
|
||||
data["features"] = data["features"][offset : offset + limit]
|
||||
data["numberReturned"] = len(data["features"])
|
||||
|
||||
return data
|
||||
|
||||
@crs_transform
|
||||
def get(self, identifier, **kwargs):
|
||||
"""
|
||||
query the provider by id
|
||||
:param identifier: feature id
|
||||
:returns: dict of single GeoJSON feature
|
||||
"""
|
||||
|
||||
all_data = self._load()
|
||||
# if matches
|
||||
for feature in all_data["features"]:
|
||||
if str(feature.get("id")) == identifier:
|
||||
return feature
|
||||
# default, no match
|
||||
err = f"item {identifier} not found"
|
||||
LOGGER.error(err)
|
||||
raise ProviderItemNotFoundError(err)
|
||||
|
||||
def create(self, new_feature):
|
||||
"""Create a new feature
|
||||
:param new_feature: new GeoJSON feature dictionary
|
||||
"""
|
||||
|
||||
raise NotImplementedError("Creating features is not supported")
|
||||
|
||||
def update(self, identifier, new_feature):
|
||||
"""Updates an existing feature id with new_feature
|
||||
:param identifier: feature id
|
||||
:param new_feature: new GeoJSON feature dictionary
|
||||
"""
|
||||
|
||||
raise NotImplementedError("Updating features is not supported")
|
||||
|
||||
def delete(self, identifier):
|
||||
"""Deletes an existing feature
|
||||
:param identifier: feature id
|
||||
"""
|
||||
|
||||
raise NotImplementedError("Deleting features is not supported")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SpeckleProvider> {self.data}"
|
||||
|
||||
def load_speckle_data(self: str) -> Dict:
|
||||
"""Receive and process Speckle data, return geojson."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pygeoapi.provider.speckle_utils.server_utils import get_stream_branch, get_client, get_comments, set_actions
|
||||
|
||||
from specklepy.objects.base import Base
|
||||
from specklepy.logging.exceptions import SpeckleException
|
||||
from specklepy.api import operations
|
||||
from specklepy.core.api.wrapper import StreamWrapper
|
||||
from specklepy.core.api.client import SpeckleClient
|
||||
from specklepy.logging.metrics import set_host_app
|
||||
from specklepy.transports.server import ServerTransport
|
||||
|
||||
set_host_app(HOST_APP, "0.0.99")
|
||||
|
||||
# get URL that will not trigget Client init
|
||||
url_proj: str = self.speckle_url.split("models")[0]
|
||||
wrapper: StreamWrapper = StreamWrapper(url_proj)
|
||||
|
||||
# set actual branch
|
||||
wrapper.model_id = self.speckle_url.split("models/")[1].split(" ")[0].split("/")[0].split("&")[0].split(",")[0].split(";")[0].split("@")[0]
|
||||
|
||||
# get stream and branch data
|
||||
client = get_client(wrapper, url_proj)
|
||||
stream, branch = get_stream_branch(self, client, wrapper)
|
||||
if stream is None:
|
||||
raise ValueError(f"Project from URL '{url_proj}' not found")
|
||||
if branch is None:
|
||||
raise ValueError(f"Model '{wrapper.model_id}' of the project '{stream['name']}' not found")
|
||||
|
||||
if self.requested_data_type == "projectcomments":
|
||||
comments = get_comments(client, wrapper.stream_id, wrapper.model_id)
|
||||
# commit_obj = Base() # still need to receive object to get the CRS
|
||||
else:
|
||||
comments = {}
|
||||
|
||||
# set the Model name
|
||||
self.project_id = wrapper.stream_id
|
||||
self.project_name = stream['name']
|
||||
self.model_name = branch['name']
|
||||
|
||||
commit = branch["commits"]["items"][0]
|
||||
objId = commit["referencedObject"]
|
||||
self.sourceApp = commit["sourceApplication"]
|
||||
|
||||
transport = ServerTransport(client=client, account=client.account, stream_id=wrapper.stream_id)
|
||||
if transport == None:
|
||||
raise SpeckleException("Transport not found")
|
||||
|
||||
# receive commit
|
||||
set_actions(self, client)
|
||||
try:
|
||||
commit_obj = operations.receive(objId, transport, None)
|
||||
except Exception as ex:
|
||||
# e.g. SpeckleException: Can't get object b53a53697a/f8ce82b242e05eeaab4c6c59fb25e4a0: HTTP error 404 ()
|
||||
raise ex
|
||||
|
||||
client.commit.received(
|
||||
wrapper.stream_id,
|
||||
commit["id"],
|
||||
source_application="pygeoapi",
|
||||
message="Received commit in pygeoapi",
|
||||
)
|
||||
|
||||
print(f"_{datetime.now().astimezone(timezone.utc)} _Rendering model '{branch['name']}' of the project '{stream['name']}'")
|
||||
speckle_data = self.traverse_data(commit_obj, comments)
|
||||
|
||||
set_actions(self, client, "GEO post-receive")
|
||||
|
||||
speckle_data["features"].extend(speckle_data["comments"])
|
||||
speckle_data["comments"] = []
|
||||
|
||||
speckle_data["project_id"] = wrapper.stream_id
|
||||
speckle_data["project"] = stream['name']
|
||||
speckle_data["model"] = branch['name']
|
||||
speckle_data["model_last_version_date"] = datetime.strptime(commit['createdAt'].replace("T", " ").replace("Z","").split(".")[0], '%Y-%m-%d %H:%M:%S')
|
||||
speckle_data["model_id"] = wrapper.model_id
|
||||
speckle_data["extent"] = self.extent
|
||||
speckle_data["extent3d"] = self.extent3d
|
||||
speckle_data["limit_message"] = self.limit_message
|
||||
|
||||
return speckle_data
|
||||
|
||||
def traverse_data(self, commit_obj, comments) -> Dict:
|
||||
"""Traverse Speckle commit and return geojson with features."""
|
||||
|
||||
from specklepy.objects.geometry import Point, Line, Curve, Arc, Circle, Ellipse, Polyline, Polycurve, Mesh, Brep
|
||||
from specklepy.objects.GIS.layers import VectorLayer
|
||||
from specklepy.objects.GIS.geometry import GisPolygonElement
|
||||
from specklepy.objects.GIS.GisFeature import GisFeature
|
||||
from specklepy.objects.graph_traversal.traversal import (
|
||||
GraphTraversal,
|
||||
TraversalRule,
|
||||
)
|
||||
from pygeoapi.provider.speckle_utils.crs_utils import get_set_crs_settings
|
||||
from pygeoapi.provider.speckle_utils.feature_utils import create_features
|
||||
from pygeoapi.provider.speckle_utils.display_utils import isDisplayable, set_default_color, get_material_color_proxies
|
||||
|
||||
supported_classes = [GisFeature, GisPolygonElement, Mesh, Brep, Point, Line, Polyline, Curve, Arc, Circle, Ellipse, Polycurve]
|
||||
supported_types = [y().speckle_type for y in supported_classes]
|
||||
supported_types.extend([
|
||||
"Objects.Other.Revit.RevitInstance",
|
||||
"Objects.BuiltElements.Revit.RevitWall",
|
||||
"Objects.BuiltElements.Revit.RevitFloor",
|
||||
"Objects.BuiltElements.Revit.RevitStair",
|
||||
"Objects.BuiltElements.Revit.RevitColumn",
|
||||
"Objects.BuiltElements.Revit.RevitBeam",
|
||||
"Objects.BuiltElements.Revit.RevitElement",
|
||||
"Objects.BuiltElements.Revit.RevitRebar"])
|
||||
|
||||
# traverse commit
|
||||
data: Dict[str, Any] = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [],
|
||||
"comments": [],
|
||||
"extent": [-180,-90,180,90],
|
||||
"model_crs": "-",
|
||||
}
|
||||
|
||||
# rule to keep traversing the object's "x" attribute "item" (both conditions need to be fulfilled)
|
||||
# 1. if the item type is not in supported (convertible) types or is GIS VectorLayer
|
||||
# 2. if the item's value is a list or a GH object
|
||||
rule = TraversalRule(
|
||||
[lambda _: True],
|
||||
lambda x: [
|
||||
item
|
||||
for item in x.get_member_names()
|
||||
if (x.speckle_type.split(":")[-1] not in supported_types or isinstance(x, VectorLayer))
|
||||
and (isinstance(getattr(x, item, None), list) or (self.sourceApp is not None and "grasshopper" in self.sourceApp.lower() and x.speckle_type == "Base") )
|
||||
],
|
||||
)
|
||||
|
||||
# for the context list, save the displayable objects and Layers (for getting CRS for now)
|
||||
context_list = [x for x in GraphTraversal([rule]).traverse(commit_obj) if isDisplayable(x.current) or x.current.speckle_type.endswith("VectorLayer")]
|
||||
|
||||
get_set_crs_settings(self, commit_obj, context_list, data)
|
||||
|
||||
set_default_color(context_list)
|
||||
self.material_color_proxies: dict = get_material_color_proxies(commit_obj)
|
||||
|
||||
create_features(self, context_list, comments, data)
|
||||
|
||||
# sort features by height
|
||||
|
||||
#if len(data['features']) == len(data['heights']):
|
||||
#feat_array = np.array(data['features'])
|
||||
#heights_array = np.array(data['heights'])
|
||||
#inds = heights_array.argsort()
|
||||
#sorted = feat_array[inds].tolist()
|
||||
time1 = datetime.now()
|
||||
sorted_list = sorted(data['features'], key=lambda d: d['max_height'])
|
||||
for i, _ in enumerate(sorted_list):
|
||||
sorted_list[i]["properties"]["FID"] = i+1
|
||||
data['features'] = sorted_list
|
||||
time2 = datetime.now()
|
||||
|
||||
time_operation = (time2-time1).total_seconds()
|
||||
self.times["time_sort"] = time_operation
|
||||
# print(f"Sorting time: {time_operation}")
|
||||
|
||||
return data
|
||||
|
||||
def get_python_path(self) -> str:
|
||||
"""Get current Python executable path."""
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
return sys.executable
|
||||
pythonExec = os.path.dirname(sys.executable)
|
||||
if sys.platform == "win32":
|
||||
pythonExec += "\\python"
|
||||
else:
|
||||
pythonExec += "/bin/python3"
|
||||
return pythonExec
|
||||
@@ -1,477 +0,0 @@
|
||||
|
||||
import math
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
def convert_point(f_base: "Point", coords, coord_counts):
|
||||
"""Convert Point."""
|
||||
|
||||
coords.append([f_base.x, f_base.y, f_base.z])
|
||||
coord_counts.append([1])
|
||||
|
||||
def convert_line(f_base: "Line", coords, coord_counts):
|
||||
"""Convert Line."""
|
||||
|
||||
start = [f_base.start.x, f_base.start.y, f_base.start.z]
|
||||
end = [f_base.end.x, f_base.end.y, f_base.end.z]
|
||||
|
||||
coords.extend([start, end])
|
||||
coord_counts.append([2])
|
||||
|
||||
def convert_polyline(f_base: "Polyline", coords, coord_counts):
|
||||
"""Convert Polyline."""
|
||||
|
||||
coord_counts.append([])
|
||||
local_coords = [] # to keep track of just the current polyline
|
||||
local_poly_count = 0
|
||||
|
||||
for pt in f_base.as_points():
|
||||
coords.append([pt.x, pt.y, pt.z])
|
||||
local_coords.append([pt.x, pt.y, pt.z])
|
||||
local_poly_count += 1
|
||||
|
||||
# closing point
|
||||
if local_poly_count>2 and f_base.closed is True and local_coords[0] != local_coords[-1]:
|
||||
coords.append(local_coords[0])
|
||||
local_poly_count += 1
|
||||
coord_counts[-1].append(local_poly_count)
|
||||
|
||||
def convert_arc(f_base: "Arc", coords, coord_counts):
|
||||
"""Convert Arc."""
|
||||
|
||||
if f_base.plane is None or f_base.plane.normal.z == 0:
|
||||
normal = 1
|
||||
else:
|
||||
normal = f_base.plane.normal.z
|
||||
|
||||
# calculate angles and interval
|
||||
interval, angle1, angle2 = getArcRadianAngle(f_base)
|
||||
|
||||
if (angle1 > angle2 and normal == -1) or (angle2 > angle1 and normal == 1):
|
||||
pass
|
||||
if angle1 > angle2 and normal == 1:
|
||||
interval = abs((2 * math.pi - angle1) + angle2)
|
||||
if angle2 > angle1 and normal == -1:
|
||||
interval = abs((2 * math.pi - angle2) + angle1)
|
||||
|
||||
# set a (random) point density: 24 per 1 rad
|
||||
pointsNum = math.floor(abs(interval)) * 24
|
||||
if pointsNum < 4:
|
||||
pointsNum = 4
|
||||
|
||||
# assign coordinates
|
||||
coord_counts.append([])
|
||||
local_poly_count = 0
|
||||
|
||||
for i in range(0, pointsNum + 1):
|
||||
k = i / pointsNum # reset values to fraction
|
||||
angle = angle1 + k * interval * normal
|
||||
|
||||
x=f_base.plane.origin.x + f_base.radius * math.cos(angle)
|
||||
y=f_base.plane.origin.y + f_base.radius * math.sin(angle)
|
||||
z=f_base.plane.origin.z
|
||||
|
||||
coords.append([x, y, z])
|
||||
local_poly_count += 1
|
||||
coord_counts[-1].append(local_poly_count)
|
||||
|
||||
def convert_circle(f_base: "Circle", coords, coord_counts):
|
||||
"""Convert Circle."""
|
||||
|
||||
if f_base.plane is None or f_base.plane.normal.z == 0:
|
||||
normal = 1
|
||||
else:
|
||||
normal = f_base.plane.normal.z
|
||||
|
||||
# set a (random) point density: 24 per 1 rad
|
||||
interval = 2 * math.pi
|
||||
pointsNum = math.floor(abs(interval)) * 24
|
||||
if pointsNum < 4:
|
||||
pointsNum = 4
|
||||
|
||||
# assign coordinates
|
||||
coord_counts.append([])
|
||||
local_poly_count = 0
|
||||
|
||||
for i in range(0, pointsNum + 1):
|
||||
k = i / pointsNum # reset values to fraction
|
||||
angle = k * interval * normal
|
||||
|
||||
x=f_base.plane.origin.x + f_base.radius * math.cos(angle)
|
||||
y=f_base.plane.origin.y + f_base.radius * math.sin(angle)
|
||||
z=f_base.plane.origin.z
|
||||
|
||||
coords.append([x, y, z])
|
||||
local_poly_count += 1
|
||||
coord_counts[-1].append(local_poly_count)
|
||||
|
||||
def convert_polycurve(f_base: "Polycurve", coords, coord_counts):
|
||||
"""Convert Polycurve."""
|
||||
|
||||
flat_coords = []
|
||||
flat_coord_count = [0]
|
||||
|
||||
# put together results from all segment conversions
|
||||
for segm in f_base.segments:
|
||||
convert_icurve(segm, coords, coord_counts)
|
||||
if len(coord_counts)==0:
|
||||
continue
|
||||
flat_coords.extend(coords)
|
||||
flat_coord_count[-1] += coord_counts[-1][-1]
|
||||
|
||||
coords = flat_coords
|
||||
coord_counts = flat_coord_count
|
||||
|
||||
def convert_curve(f_base: "Curve", coords, coord_counts):
|
||||
"""Convert Curve using its Polyline displayValue."""
|
||||
|
||||
return convert_polyline(f_base.displayValue, coords, coord_counts)
|
||||
|
||||
def convert_icurve(f_base: "Base", coords, coord_counts):
|
||||
"""Convert any ICurve."""
|
||||
|
||||
from specklepy.objects.geometry import Line, Polyline, Arc, Curve, Circle, Polycurve, Mesh, Brep
|
||||
|
||||
if isinstance(f_base, Line):
|
||||
convert_line(f_base, coords, coord_counts)
|
||||
|
||||
elif isinstance(f_base, Polyline):
|
||||
convert_polyline(f_base, coords, coord_counts)
|
||||
|
||||
elif isinstance(f_base, Curve):
|
||||
convert_curve(f_base, coords, coord_counts)
|
||||
|
||||
elif isinstance(f_base, Arc):
|
||||
convert_arc(f_base, coords, coord_counts)
|
||||
|
||||
elif isinstance(f_base, Circle):
|
||||
convert_circle(f_base, coords, coord_counts)
|
||||
|
||||
elif isinstance(f_base, Polycurve):
|
||||
convert_polycurve(f_base, coords, coord_counts)
|
||||
|
||||
def convert_mesh_or_brep(f_base: "Base", coords, coord_counts):
|
||||
"""Convert Mesh object or Mesh derived from Brep display value."""
|
||||
from specklepy.objects.geometry import Mesh, Brep
|
||||
|
||||
faces = []
|
||||
vertices = []
|
||||
|
||||
# get faces and vertices
|
||||
if isinstance(f_base, Mesh):
|
||||
faces = f_base.faces
|
||||
vertices = f_base.vertices
|
||||
elif isinstance(f_base, Brep):
|
||||
if f_base.displayValue is None or (
|
||||
isinstance(f_base.displayValue, list)
|
||||
and len(f_base.displayValue) == 0
|
||||
):
|
||||
geometry = {}
|
||||
return
|
||||
elif isinstance(f_base.displayValue, list):
|
||||
faces = f_base.displayValue[0].faces
|
||||
vertices = f_base.displayValue[0].vertices
|
||||
else:
|
||||
faces = f_base.displayValue.faces
|
||||
vertices = f_base.displayValue.vertices
|
||||
|
||||
# add coordinates
|
||||
count: int = 0
|
||||
|
||||
for i, pt_count in enumerate(faces):
|
||||
if i != count:
|
||||
continue
|
||||
|
||||
# old encoding
|
||||
if pt_count == 0:
|
||||
pt_count = 3
|
||||
elif pt_count == 1:
|
||||
pt_count = 4
|
||||
|
||||
local_coords_count = [pt_count]
|
||||
local_coords = []
|
||||
for vertex_index in faces[count + 1 : count + 1 + pt_count]:
|
||||
x = vertices[vertex_index * 3]
|
||||
y = vertices[vertex_index * 3 + 1]
|
||||
z = vertices[vertex_index * 3 + 2]
|
||||
local_coords.append([x, y, z])
|
||||
|
||||
count += pt_count + 1
|
||||
valid: bool = fix_polygon_orientation(local_coords, True)
|
||||
#if valid:
|
||||
coords.extend(local_coords)
|
||||
coord_counts.append(local_coords_count)
|
||||
|
||||
def convert_polygon(polygon: "Base", coords, coord_counts):
|
||||
"""Convert GisPolygonGeometry."""
|
||||
|
||||
coord_counts.append([])
|
||||
|
||||
local_coords_count = 0
|
||||
local_coords = []
|
||||
for pt in polygon.boundary.as_points():
|
||||
local_coords.append([pt.x, pt.y, pt.z])
|
||||
local_coords_count += 1
|
||||
|
||||
valid: bool = fix_polygon_orientation(local_coords, True)
|
||||
#if valid:
|
||||
coords.extend(local_coords)
|
||||
coord_counts[-1].append(local_coords_count)
|
||||
|
||||
for void in polygon.voids:
|
||||
local_coords_count = 0
|
||||
local_coords = []
|
||||
for pt_void in void.as_points():
|
||||
local_coords.append([pt_void.x, pt_void.y, pt_void.z])
|
||||
local_coords_count += 1
|
||||
|
||||
valid: bool = fix_polygon_orientation(local_coords, False)
|
||||
#if valid:
|
||||
coords.extend(local_coords)
|
||||
coord_counts[-1].append(local_coords_count)
|
||||
|
||||
def convert_hatch(hatch: "Base", coords, coord_counts):
|
||||
"""Convert Hatch."""
|
||||
|
||||
coord_counts.append([])
|
||||
|
||||
loops: list = hatch["loops"]
|
||||
boundary = None
|
||||
voids = []
|
||||
for loop in loops:
|
||||
if len(loops)==1 or loop["Type"] == 1: # Outer
|
||||
boundary = loop["Curve"]
|
||||
else:
|
||||
voids.append(loop["Curve"])
|
||||
if boundary is None:
|
||||
return
|
||||
|
||||
# record coordinates
|
||||
local_coords_count = []
|
||||
local_coords = []
|
||||
convert_icurve(boundary, local_coords, local_coords_count)
|
||||
valid: bool = fix_polygon_orientation(local_coords, True)
|
||||
#if valid:
|
||||
coords.extend(local_coords)
|
||||
coord_counts.extend(local_coords_count)
|
||||
|
||||
for void in voids:
|
||||
local_coords_count = []
|
||||
local_coords = []
|
||||
convert_icurve(void, local_coords, local_coords_count)
|
||||
valid: bool = fix_polygon_orientation(local_coords, False)
|
||||
#if valid:
|
||||
coords.extend(local_coords)
|
||||
coord_counts.extend(local_coords_count)
|
||||
|
||||
|
||||
def assign_geometry(self: "SpeckleProvider", feature: Dict, f_base) -> Tuple[ List[List[List[float]]], List[List[None| List[int]]] ]:
|
||||
"""Assign geom type and convert object coords into flat lists of coordinates and schema."""
|
||||
|
||||
from specklepy.objects.geometry import Base, Point, Line, Polyline, Arc, Curve, Circle, Polycurve, Mesh, Brep
|
||||
from specklepy.objects.GIS.geometry import GisPolygonGeometry
|
||||
|
||||
geometry = feature["geometry"]
|
||||
coords = []
|
||||
coord_counts = []
|
||||
|
||||
if isinstance(f_base, Base) and f_base.speckle_type.endswith("Feature") and len(f_base["geometry"]) > 0: # isinstance(f_base, GisFeature) and len(f_base.geometry) > 0:
|
||||
# GisFeature doesn't deserialize properly, need to check for speckle_type
|
||||
|
||||
if self.requested_data_type == "points" and isinstance(f_base["geometry"][0], Point):
|
||||
geometry["type"] = "MultiPoint"
|
||||
coord_counts.append(None) # as an indicator of a Multi..type
|
||||
|
||||
for geom in f_base["geometry"]:
|
||||
convert_point(geom, coords, coord_counts)
|
||||
|
||||
elif self.requested_data_type == "lines" and isinstance(f_base["geometry"][0], Polyline):
|
||||
geometry["type"] = "MultiLineString"
|
||||
coord_counts.append(None)
|
||||
|
||||
for geom in f_base["geometry"]:
|
||||
convert_polyline(geom, coords, coord_counts)
|
||||
|
||||
elif self.requested_data_type.startswith("polygons") and isinstance(f_base["geometry"][0], GisPolygonGeometry):
|
||||
geometry["type"] = "MultiPolygon"
|
||||
coord_counts.append(None)
|
||||
|
||||
polygon_3d = False
|
||||
|
||||
for mesh in f_base["displayValue"]:
|
||||
for i, coord in enumerate(mesh.vertices):
|
||||
if i>60:
|
||||
break
|
||||
if i%3 !=0:
|
||||
continue
|
||||
elif coord != 0:
|
||||
polygon_3d = True
|
||||
break
|
||||
|
||||
if polygon_3d is False:
|
||||
for geom in f_base["geometry"]:
|
||||
convert_polygon(geom, coords, coord_counts)
|
||||
else:
|
||||
for geom in f_base["displayValue"]:
|
||||
convert_mesh_or_brep(geom, coords, coord_counts)
|
||||
|
||||
|
||||
elif self.requested_data_type == "points":
|
||||
if isinstance(f_base, Point):
|
||||
geometry["type"] = "MultiPoint"
|
||||
coord_counts.append(None) # as an indicator of a Multi..type
|
||||
convert_point(f_base, coords, coord_counts)
|
||||
|
||||
elif isinstance(f_base, Base) and f_base.speckle_type.endswith("PointElement"):
|
||||
raise TypeError(f"Deprecated speckleType {f_base.speckle_type}. Try loading more recent data.")
|
||||
|
||||
elif self.requested_data_type == "lines":
|
||||
if (isinstance(f_base, Line) or
|
||||
isinstance(f_base, Polyline) or
|
||||
isinstance(f_base, Curve) or
|
||||
isinstance(f_base, Arc) or
|
||||
isinstance(f_base, Circle) or
|
||||
isinstance(f_base, Polycurve)):
|
||||
|
||||
geometry["type"] = "LineString"
|
||||
convert_icurve(f_base, coords, coord_counts)
|
||||
|
||||
elif isinstance(f_base, Base) and f_base.speckle_type.endswith("LineElement"):
|
||||
raise TypeError(f"Deprecated speckleType {f_base.speckle_type}. Try loading more recent data.")
|
||||
|
||||
elif self.requested_data_type.startswith("polygons"):
|
||||
if isinstance(f_base, Base) and f_base.speckle_type.endswith(".Hatch"):
|
||||
geometry["type"] = "MultiPolygon"
|
||||
coord_counts.append(None)
|
||||
convert_hatch(f_base, coords, coord_counts)
|
||||
|
||||
elif isinstance(f_base, Mesh) or isinstance(f_base, Brep):
|
||||
geometry["type"] = "MultiPolygon"
|
||||
coord_counts.append(None) # as an indicator of a Multi..type
|
||||
convert_mesh_or_brep(f_base, coords, coord_counts)
|
||||
|
||||
elif isinstance(f_base, Base) and f_base.speckle_type.endswith("PolygonElement"):
|
||||
raise TypeError(f"Deprecated speckleType {f_base.speckle_type}. Try loading more recent data.")
|
||||
|
||||
elif self.requested_data_type == "projectcomments":
|
||||
if isinstance(f_base, List): # comment position
|
||||
geometry["type"] = "MultiPoint"
|
||||
coord_counts.append(None) # as an indicator of a Multi..type
|
||||
|
||||
coords.append([f_base[0], f_base[1], f_base[2]])
|
||||
coord_counts.append([1])
|
||||
|
||||
else:
|
||||
geometry = {}
|
||||
# print(f"Unsupported geometry type: {f_base.speckle_type}")
|
||||
|
||||
return coords, coord_counts
|
||||
|
||||
|
||||
def getArcRadianAngle(arc: "Arc") -> List[float]:
|
||||
"""Calculate start & end angle, and interval of an Arc."""
|
||||
|
||||
interval = None
|
||||
normal = arc.plane.normal.z
|
||||
angle1, angle2 = getArcAngles(arc)
|
||||
if angle1 is None or angle2 is None:
|
||||
return None
|
||||
interval = abs(angle2 - angle1)
|
||||
|
||||
if (angle1 > angle2 and normal == -1) or (angle2 > angle1 and normal == 1):
|
||||
pass
|
||||
if angle1 > angle2 and normal == 1:
|
||||
interval = abs((2 * math.pi - angle1) + angle2)
|
||||
if angle2 > angle1 and normal == -1:
|
||||
interval = abs((2 * math.pi - angle2) + angle1)
|
||||
return interval, angle1, angle2
|
||||
|
||||
|
||||
def getArcAngles(poly: "Arc") -> Tuple[float | None]:
|
||||
|
||||
if poly.startPoint.x == poly.plane.origin.x:
|
||||
angle1 = math.pi / 2
|
||||
else:
|
||||
angle1 = math.atan(
|
||||
abs(
|
||||
(poly.startPoint.y - poly.plane.origin.y)
|
||||
/ (poly.startPoint.x - poly.plane.origin.x)
|
||||
)
|
||||
) # between 0 and pi/2
|
||||
|
||||
if (
|
||||
poly.plane.origin.x < poly.startPoint.x
|
||||
and poly.plane.origin.y > poly.startPoint.y
|
||||
):
|
||||
angle1 = 2 * math.pi - angle1
|
||||
if (
|
||||
poly.plane.origin.x > poly.startPoint.x
|
||||
and poly.plane.origin.y > poly.startPoint.y
|
||||
):
|
||||
angle1 = math.pi + angle1
|
||||
if (
|
||||
poly.plane.origin.x > poly.startPoint.x
|
||||
and poly.plane.origin.y < poly.startPoint.y
|
||||
):
|
||||
angle1 = math.pi - angle1
|
||||
|
||||
if poly.endPoint.x == poly.plane.origin.x:
|
||||
angle2 = math.pi / 2
|
||||
else:
|
||||
angle2 = math.atan(
|
||||
abs(
|
||||
(poly.endPoint.y - poly.plane.origin.y)
|
||||
/ (poly.endPoint.x - poly.plane.origin.x)
|
||||
)
|
||||
) # between 0 and pi/2
|
||||
|
||||
if (
|
||||
poly.plane.origin.x < poly.endPoint.x
|
||||
and poly.plane.origin.y > poly.endPoint.y
|
||||
):
|
||||
angle2 = 2 * math.pi - angle2
|
||||
if (
|
||||
poly.plane.origin.x > poly.endPoint.x
|
||||
and poly.plane.origin.y > poly.endPoint.y
|
||||
):
|
||||
angle2 = math.pi + angle2
|
||||
if (
|
||||
poly.plane.origin.x > poly.endPoint.x
|
||||
and poly.plane.origin.y < poly.endPoint.y
|
||||
):
|
||||
angle2 = math.pi - angle2
|
||||
|
||||
return angle1, angle2
|
||||
|
||||
|
||||
def fix_polygon_orientation(
|
||||
polygon_pts: List[List[float]], clockwise: bool = True
|
||||
) -> bool:
|
||||
"""Changes orientation to clockwise (or counter-) and returns False if polygon has no footprint."""
|
||||
|
||||
max_number_of_points = 1000
|
||||
coef = int(len(polygon_pts)/max_number_of_points) if len(polygon_pts)>max_number_of_points else 1
|
||||
|
||||
sum_orientation = 0
|
||||
for k, _ in enumerate(polygon_pts):
|
||||
index = k + 1
|
||||
if k == len(polygon_pts) - 1:
|
||||
index = 0
|
||||
|
||||
try:
|
||||
pt = polygon_pts[k * coef]
|
||||
pt2 = polygon_pts[index * coef]
|
||||
|
||||
sum_orientation += (pt2[0] - pt[0]) * (pt2[1] + pt[1]) # if Speckle Points
|
||||
except IndexError:
|
||||
break
|
||||
|
||||
if clockwise is True and sum_orientation < 0:
|
||||
polygon_pts.reverse()
|
||||
elif clockwise is False and sum_orientation > 0:
|
||||
polygon_pts.reverse()
|
||||
|
||||
if sum_orientation ==0:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
|
||||
import copy
|
||||
import math
|
||||
from typing import List
|
||||
from pygeoapi.provider.speckle_utils.legal import COUNTRY_CODES, STATES, POSTCODES
|
||||
|
||||
|
||||
def reproject_bulk(self, all_coords: List[List[List[float]]], all_coord_counts: List[List[None| List[int]]], geometries) -> None:
|
||||
"""Reproject coordinates and assign to corresponding geometries."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
# reproject all coords
|
||||
time1 = datetime.now()
|
||||
flat_coords = reproject_2d_coords_list(self, all_coords)
|
||||
time2 = datetime.now()
|
||||
time_operation = (time2-time1).total_seconds()
|
||||
self.times["time_reproject"] = time_operation
|
||||
|
||||
validate_coords(self, flat_coords[0])
|
||||
if len(flat_coords)>2:
|
||||
validate_coords(self, flat_coords[len(flat_coords)-1])
|
||||
|
||||
# define type of features
|
||||
feat_coord_group_is_multi = [True if None in x else False for x in all_coord_counts]
|
||||
|
||||
feat_coord_group_counts = [[ y for y in x if y is not None] for x in all_coord_counts]
|
||||
feat_coord_group_counts_per_part = [[ sum(y) for y in x if y is not None] for x in all_coord_counts]
|
||||
|
||||
feat_coord_group_flat_counts: List[int] = [sum([ sum(y) for y in x if y is not None]) for x in all_coord_counts]
|
||||
|
||||
feat_coord_groups = [flat_coords[sum(feat_coord_group_flat_counts[:i]):sum(feat_coord_group_flat_counts[:i])+x] for i, x in enumerate(feat_coord_group_flat_counts)]
|
||||
|
||||
for i, geometry in enumerate(geometries):
|
||||
geometry["coordinates"] = []
|
||||
if feat_coord_group_is_multi[i] is False:
|
||||
|
||||
if geometry["type"] == "Point":
|
||||
geometry["coordinates"].extend(feat_coord_groups[i][0])
|
||||
else:
|
||||
geometry["coordinates"].extend(feat_coord_groups[i])
|
||||
else:
|
||||
polygon_parts = []
|
||||
local_coords_count: List[List[int]] = feat_coord_group_counts[i]
|
||||
local_coords_count_flat: List[int] = feat_coord_group_counts_per_part[i]
|
||||
local_flat_coords: List[int] = feat_coord_groups[i]
|
||||
|
||||
for c, poly_part_count_lists in enumerate(local_coords_count):
|
||||
poly_part = []
|
||||
start_index = sum(local_coords_count_flat[:c]) if c!=0 else 0 # all used coords in all parts
|
||||
|
||||
for part_count in poly_part_count_lists:
|
||||
range_coords_indices = range(start_index, start_index + part_count)
|
||||
|
||||
if geometry["type"] == "MultiPoint":
|
||||
poly_part.extend([local_flat_coords[ind] for ind in range_coords_indices])
|
||||
else:
|
||||
new_list = []
|
||||
for ind in range_coords_indices:
|
||||
try:
|
||||
new_list.append(local_flat_coords[ind])
|
||||
except Exception as e: # corrupted geometry, ignore altogether
|
||||
new_list = []
|
||||
break
|
||||
if len(new_list)>0:
|
||||
poly_part.append(new_list)
|
||||
|
||||
start_index += part_count
|
||||
|
||||
if geometry["type"] in ["MultiPoint","MultiLineString"] :
|
||||
polygon_parts.extend(poly_part)
|
||||
else:
|
||||
polygon_parts.append(poly_part)
|
||||
|
||||
geometry["coordinates"].extend(polygon_parts)
|
||||
|
||||
time3 = datetime.now()
|
||||
|
||||
time_operation = (time3-time2).total_seconds()
|
||||
self.times["time_reconstruct_geometry"] = time_operation
|
||||
# print(f"Construct back geometry time: {time_operation}")
|
||||
|
||||
def reproject_2d_coords_list(self, coords_in: List[List[float]]) -> List[List[float]]:
|
||||
"""Return coordinates in a CRS of SpeckleProvider."""
|
||||
|
||||
from pyproj import Transformer
|
||||
from pyproj import CRS
|
||||
|
||||
coords_offset = offset_rotate(self, copy.deepcopy(coords_in))
|
||||
|
||||
transformer = Transformer.from_crs(
|
||||
self.crs,
|
||||
CRS.from_user_input(4326),
|
||||
always_xy=True,
|
||||
)
|
||||
transformed = [[pt[0], pt[1], pt[2]] for pt in transformer.itransform(coords_offset)]
|
||||
|
||||
all_x = [x[0] for x in transformed]
|
||||
all_y = [x[1] for x in transformed]
|
||||
all_z = [x[2] for x in transformed]
|
||||
self.extent = [min(all_x), min(all_y), max(all_x), max(all_y)]
|
||||
self.extent3d = [min(all_x), min(all_y), min(all_z), max(all_x), max(all_y), max(all_z)]
|
||||
return transformed
|
||||
|
||||
def offset_rotate(self, coords_in: List[list]) -> List[List[float]]:
|
||||
"""Apply offset and rotation to coordinates, according to SpeckleProvider CRS_dict."""
|
||||
|
||||
from specklepy.objects.units import get_scale_factor_from_string
|
||||
|
||||
scale_factor = 1
|
||||
if isinstance(self.crs_dict["units_native"], str):
|
||||
scale_factor = get_scale_factor_from_string(self.crs_dict["units_native"], "m")
|
||||
|
||||
final_coords = []
|
||||
for coord in coords_in:
|
||||
a = self.crs_dict["rotation"] * math.pi / 180
|
||||
x2 = coord[0] * math.cos(a) - coord[1] * math.sin(a)
|
||||
y2 = coord[0] * math.sin(a) + coord[1] * math.cos(a)
|
||||
final_coords.append(
|
||||
[
|
||||
scale_factor * (x2 + self.crs_dict["offset_x"]),
|
||||
scale_factor * (y2 + self.crs_dict["offset_y"]),
|
||||
scale_factor * (coord[2]),
|
||||
]
|
||||
)
|
||||
|
||||
return final_coords
|
||||
|
||||
def validate_coords(self, coords):
|
||||
from geopy.geocoders import Nominatim
|
||||
country_code = ""
|
||||
state = ""
|
||||
postcode = ""
|
||||
try:
|
||||
geolocator = Nominatim(user_agent="specklePygeoapi")
|
||||
coord = f"{coords[1]}, {coords[0]}"
|
||||
location = geolocator.reverse(coord, exactly_one=True)
|
||||
if location is not None:
|
||||
address = location.raw['address']
|
||||
country_code = address.get('country_code', '')
|
||||
state = address.get('state', '')
|
||||
postcode = address.get('postcode', '')
|
||||
except Exception as e:
|
||||
print(f"Error validating project location: {e}")
|
||||
self.country_code = country_code
|
||||
|
||||
if country_code in COUNTRY_CODES or state in STATES or postcode in POSTCODES:
|
||||
print(f"Validating project location: blocked LAT LON {coords[1]}, {coords[0]}, {country_code}, {state}, {postcode}")
|
||||
raise PermissionError("Review Speckle Terms and Conditions")
|
||||
@@ -1,102 +0,0 @@
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def create_crs_from_wkt(self: "SpeckleProvider", wkt: str | None) -> None:
|
||||
"""Create and assign CRS object from WKT string."""
|
||||
|
||||
from pyproj import CRS
|
||||
self.crs = CRS.from_user_input(wkt)
|
||||
|
||||
|
||||
def create_crs_from_authid(self: "SpeckleProvider", authid: str | None) -> None:
|
||||
"""Create and assign CRS object from Authority ID."""
|
||||
|
||||
from pyproj import CRS
|
||||
|
||||
crs_obj = CRS.from_string(authid)
|
||||
self.crs = crs_obj
|
||||
|
||||
|
||||
def create_crs_default(self: "SpeckleProvider") -> None:
|
||||
"""Create and assign custom CRS using SpeckleProvider Lat & Lon."""
|
||||
|
||||
from pyproj import CRS
|
||||
|
||||
wkt = f'PROJCS["SpeckleCRS_latlon_{self.lat}_{self.lon}", GEOGCS["GCS_WGS_1984", DATUM["D_WGS_1984", SPHEROID["WGS_1984", 6378137.0, 298.257223563]], PRIMEM["Greenwich", 0.0], UNIT["Degree", 0.0174532925199433]], PROJECTION["Transverse_Mercator"], PARAMETER["False_Easting", 0.0], PARAMETER["False_Northing", 0.0], PARAMETER["Central_Meridian", {self.lon}], PARAMETER["Scale_Factor", 1.0], PARAMETER["Latitude_Of_Origin", {self.lat}], UNIT["Meter", 1.0]]'
|
||||
crs_obj = CRS.from_user_input(wkt)
|
||||
self.crs = crs_obj
|
||||
|
||||
def create_crs_dict(self: "SpeckleProvider", offset_x, offset_y, displayUnits: str | None) -> None:
|
||||
"""Create and assign CRS_dict of SpeckleProvider."""
|
||||
|
||||
if self.crs is not None:
|
||||
self.crs_dict = {
|
||||
"wkt": self.crs.to_wkt(),
|
||||
"offset_x": offset_x,
|
||||
"offset_y": offset_y,
|
||||
"rotation": self.north_degrees,
|
||||
"units_native": displayUnits,
|
||||
"obj": self.crs,
|
||||
}
|
||||
|
||||
|
||||
def get_set_crs_settings(self: "SpeckleProvider", commit_obj: "Base", context_list: List["TraversalContext"], data: Dict) -> None:
|
||||
"""Assign CRS object and Dict to SpeckleProvider."""
|
||||
|
||||
from pygeoapi.provider.speckle_utils.display_utils import get_display_units
|
||||
from specklepy.objects.GIS.CRS import CRS
|
||||
|
||||
assign_coordinate_system_to_geojson(data)
|
||||
|
||||
root_objects = []
|
||||
try:
|
||||
root_objects = [commit_obj] + commit_obj.elements + [c.current for c in context_list]
|
||||
except AttributeError as ex:
|
||||
pass # old commit structure
|
||||
|
||||
# iterate Speckle objects to get CRS, DisplayUnits, offsets, rotation
|
||||
crs = None
|
||||
displayUnits = None
|
||||
offset_x = 0
|
||||
offset_y = 0
|
||||
|
||||
for item in root_objects:
|
||||
if (
|
||||
crs is None
|
||||
and hasattr(item, "crs")
|
||||
and isinstance(item["crs"], CRS)
|
||||
):
|
||||
crs = item["crs"]
|
||||
displayUnits = crs["units_native"]
|
||||
offset_x = crs["offset_x"]
|
||||
offset_y = crs["offset_y"]
|
||||
self.north_degrees = crs["rotation"]
|
||||
create_crs_from_wkt(self, crs["wkt"])
|
||||
self.commit_gis = True
|
||||
|
||||
if self.crs.to_authority() is not None:
|
||||
data["model_crs"] = f"{self.crs.to_authority()}, {self.crs.name} "
|
||||
else:
|
||||
data["model_crs"] = f"{self.crs.to_proj4()}"
|
||||
break
|
||||
|
||||
# if CRS not found, create default one and get model units for scaling
|
||||
if self.crs is None:
|
||||
create_crs_default(self)
|
||||
if displayUnits is None:
|
||||
displayUnits = get_display_units(context_list)
|
||||
|
||||
create_crs_dict(self, offset_x, offset_y, displayUnits)
|
||||
|
||||
|
||||
|
||||
def assign_coordinate_system_to_geojson(data: Dict):
|
||||
|
||||
crs = {
|
||||
"crs": {
|
||||
"type": "name",
|
||||
"properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"},
|
||||
}
|
||||
}
|
||||
data["crs"] = crs
|
||||
@@ -1,480 +0,0 @@
|
||||
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
DEFAULT_COLOR = (255 << 24) + (150 << 16) + (150 << 8) + 150
|
||||
|
||||
|
||||
def find_list_of_display_obj(obj) -> List[Tuple["Base", "Base"]]:
|
||||
"""Get displayable object."""
|
||||
|
||||
list_of_display_obj_colors: List = []
|
||||
|
||||
# find displayValue if available
|
||||
displayValue = obj
|
||||
if hasattr(obj, 'displayValue'):
|
||||
displayValue = getattr(obj, 'displayValue')
|
||||
elif hasattr(obj, '@displayValue'):
|
||||
displayValue = getattr(obj, '@displayValue')
|
||||
# return List of displayValues
|
||||
if not isinstance(displayValue, List):
|
||||
displayValue = [displayValue]
|
||||
|
||||
# for Features, return original convertible object and a first item from displayValue
|
||||
if obj.speckle_type.endswith("Feature"):
|
||||
if len(displayValue)==0:
|
||||
return ([(obj, obj)])
|
||||
else:
|
||||
return([(obj, displayValue[0])])
|
||||
|
||||
separated_display_values: List[Tuple] = separate_display_vals(displayValue)
|
||||
for item, item_original in separated_display_values:
|
||||
if item is None:
|
||||
continue
|
||||
|
||||
# read displayObj Colors directly from the obj itself, unless its GisFeature or Revit Element: then keep reading from displayValue
|
||||
if obj.speckle_type.endswith("Feature") or "BuiltElements.Revit" in obj.speckle_type:
|
||||
displayValForColor = item_original
|
||||
else:
|
||||
displayValForColor = obj
|
||||
|
||||
list_of_display_obj_colors.append((item, displayValForColor))
|
||||
|
||||
return list_of_display_obj_colors
|
||||
|
||||
|
||||
def separate_display_vals(displayValue: List) -> List[Tuple["Base"]]:
|
||||
"""Return multiple split geometries."""
|
||||
|
||||
from specklepy.objects.geometry import Mesh
|
||||
|
||||
display_objs = []
|
||||
|
||||
for i, item in enumerate(displayValue):
|
||||
if isinstance(item, Mesh):
|
||||
count = 0
|
||||
all_count = len(item.faces)
|
||||
|
||||
sub_meshes = []
|
||||
for _ in item.faces:
|
||||
if count < all_count:
|
||||
faces = []
|
||||
verts = []
|
||||
colors = []
|
||||
|
||||
vert_num = item.faces[count]
|
||||
if vert_num == 0:
|
||||
vert_num = 3
|
||||
elif vert_num == 1:
|
||||
vert_num = 4
|
||||
|
||||
faces.append(vert_num)
|
||||
faces.extend([ x for x in list(range(vert_num))])
|
||||
|
||||
try:
|
||||
for ind in range(vert_num):
|
||||
face_vert_index = count+1+ind
|
||||
#print(face_vert_index)
|
||||
vert_index = item.faces[face_vert_index]
|
||||
|
||||
new_vert = item.vertices[3*vert_index : 3*vert_index + 3]
|
||||
verts.extend(new_vert)
|
||||
|
||||
if isinstance(item.colors, List) and len(item.colors) > vert_index:
|
||||
color = item.colors[vert_index]
|
||||
colors.append(color)
|
||||
|
||||
count += vert_num+1
|
||||
if len(colors)>0:
|
||||
mesh = Mesh.create(faces= faces, vertices=verts, colors=colors)
|
||||
else:
|
||||
mesh = Mesh.create(faces= faces, vertices=verts)
|
||||
|
||||
sub_meshes.append((mesh, item))
|
||||
|
||||
except IndexError: # corrupted mesh, drop altogether
|
||||
sub_meshes = []
|
||||
break
|
||||
|
||||
display_objs.extend(sub_meshes)
|
||||
|
||||
elif item is not None:
|
||||
display_objs.append((item, item))
|
||||
|
||||
return display_objs
|
||||
|
||||
def isDisplayable(obj: "Base") -> bool:
|
||||
|
||||
if is_primitive(obj):
|
||||
return True
|
||||
|
||||
if obj.speckle_type.endswith("Feature"):
|
||||
return True
|
||||
|
||||
displayValue = None
|
||||
if hasattr(obj, 'displayValue'):
|
||||
displayValue = getattr(obj, 'displayValue')
|
||||
elif hasattr(obj, '@displayValue'):
|
||||
displayValue = getattr(obj, '@displayValue')
|
||||
|
||||
# merge to sigle object, if List
|
||||
if isinstance(displayValue, List):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def find_display_obj(obj) -> Tuple["Base", "Base"]:
|
||||
"""Get displayable object."""
|
||||
|
||||
displayValObj = obj
|
||||
displayValForColor = obj
|
||||
|
||||
# find displayValue if available
|
||||
displayValue = obj
|
||||
if hasattr(obj, 'displayValue'):
|
||||
displayValue = getattr(obj, 'displayValue')
|
||||
elif hasattr(obj, '@displayValue'):
|
||||
displayValue = getattr(obj, '@displayValue')
|
||||
# merge to sigle object, if List
|
||||
if isinstance(displayValue, List):
|
||||
displayValue = get_single_display_object(displayValue)
|
||||
|
||||
# read displayObj Colors directly from the obj itself, unless its GisFeature or Revit Element: then keep reading from displayValue
|
||||
if not obj.speckle_type.endswith("Feature") and "BuiltElements.Revit" not in obj.speckle_type:
|
||||
displayValForColor = obj
|
||||
else:
|
||||
displayValForColor = displayValue
|
||||
|
||||
# return convertible types as is
|
||||
if is_convertible(obj):
|
||||
displayValObj = obj
|
||||
else:
|
||||
displayValObj = displayValue
|
||||
|
||||
return displayValObj, displayValForColor
|
||||
|
||||
def is_convertible(obj) -> bool:
|
||||
"""Check if the object can be converted directly."""
|
||||
|
||||
from specklepy.objects.geometry import Base, Point, Line, Polyline, Arc, Circle, Curve, Polycurve, Mesh, Brep
|
||||
|
||||
if ( (isinstance(obj, Base) and obj.speckle_type.endswith("Feature")) or
|
||||
isinstance(obj, Point) or
|
||||
isinstance(obj, Line) or
|
||||
isinstance(obj, Polyline) or
|
||||
isinstance(obj, Arc) or
|
||||
isinstance(obj, Circle) or
|
||||
isinstance(obj, Curve) or
|
||||
isinstance(obj, Polycurve) or
|
||||
isinstance(obj, Mesh) or
|
||||
isinstance(obj, Brep)):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_primitive(obj) -> bool:
|
||||
"""Check if the object can be converted directly."""
|
||||
|
||||
from specklepy.objects.geometry import Polyline, Point, Line, Arc, Circle, Curve, Polycurve, Mesh, Brep
|
||||
|
||||
if (
|
||||
isinstance(obj, Point) or
|
||||
isinstance(obj, Line) or
|
||||
isinstance(obj, Polyline) or
|
||||
isinstance(obj, Arc) or
|
||||
isinstance(obj, Circle) or
|
||||
isinstance(obj, Curve) or
|
||||
isinstance(obj, Mesh)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_single_display_object(displayValForColor: List) -> "Base":
|
||||
"""Get a merged Mesh or a first item from displayValue list."""
|
||||
|
||||
from specklepy.objects.geometry import Mesh
|
||||
|
||||
faces = []
|
||||
verts = []
|
||||
colors = []
|
||||
for i, item in enumerate(displayValForColor):
|
||||
if isinstance(item, Mesh):
|
||||
start_vert_count = int(len(verts)/3)
|
||||
|
||||
# only add colors if existing and incoming colors are valid (same length as vertices)
|
||||
if len(colors) == start_vert_count and isinstance(item.colors, List) and len(item.colors)== int(len(item.vertices)/3)>0:
|
||||
colors.extend(item.colors)
|
||||
else:
|
||||
colors = []
|
||||
|
||||
verts.extend(item.vertices)
|
||||
|
||||
count = 0
|
||||
for _ in item.faces:
|
||||
try:
|
||||
vert_num = item.faces[count]
|
||||
faces.append(vert_num)
|
||||
faces.extend([ x+start_vert_count for x in item.faces[count+1 : count+1+vert_num]])
|
||||
count += vert_num+1
|
||||
except IndexError:
|
||||
break
|
||||
elif item is not None:
|
||||
return item
|
||||
|
||||
mesh = Mesh.create(faces= faces, vertices=verts, colors=colors)
|
||||
|
||||
if isinstance(displayValForColor, List) and len(displayValForColor)>0:
|
||||
for prop in displayValForColor[0].get_member_names():
|
||||
if prop not in ["colors", "vertices", "faces"]:
|
||||
mesh[prop] = getattr(displayValForColor[0], prop)
|
||||
|
||||
displayValForColor = mesh
|
||||
return displayValForColor
|
||||
|
||||
def get_display_units(context_list: List["TraversalContext"]) -> None | str:
|
||||
"""Get units from either of displayable objects."""
|
||||
|
||||
from specklepy.objects.geometry import Base
|
||||
|
||||
displayUnits = None
|
||||
|
||||
for item in context_list:
|
||||
if hasattr(item.current, "displayValue"):
|
||||
try:
|
||||
displayVal = item.current["displayValue"]
|
||||
except:
|
||||
displayVal = item.current.displayValue
|
||||
if isinstance(displayVal, list) and len(displayVal)>0:
|
||||
displayUnits = displayVal[0].units
|
||||
break
|
||||
elif isinstance(displayVal, Base):
|
||||
displayUnits = item.current.units
|
||||
break
|
||||
else:
|
||||
if item.current.units is not None:
|
||||
displayUnits = item.current.units
|
||||
break
|
||||
|
||||
return displayUnits
|
||||
|
||||
def get_material_color_proxies(root_obj) -> Dict:
|
||||
"""Get colors and object IDs using ColorProxies and renderMaterialProxies."""
|
||||
obj_colors = {}
|
||||
|
||||
# first, get colors
|
||||
try:
|
||||
colorProxies = root_obj["colorProxies"]
|
||||
if isinstance(colorProxies, List):
|
||||
for proxy in colorProxies:
|
||||
color = proxy.value
|
||||
a, r, g, b = get_r_g_b(color)
|
||||
color = f'rgba({r},{g},{b},{a})'
|
||||
|
||||
for obj in proxy.objects:
|
||||
obj_colors[obj] = color
|
||||
except:
|
||||
pass
|
||||
|
||||
# overwrite with materials if available
|
||||
try:
|
||||
materialProxies = root_obj["renderMaterialProxies"]
|
||||
if isinstance(materialProxies, List):
|
||||
for proxy in materialProxies:
|
||||
material = proxy.value
|
||||
color = material['diffuse']
|
||||
opacity = material['opacity']
|
||||
|
||||
a, r, g, b = get_r_g_b(color)
|
||||
if opacity is not None and isinstance(opacity, float):
|
||||
a_test = int(255* opacity)
|
||||
if 0 <= a_test <= 255:
|
||||
a = a_test
|
||||
color = f'rgba({r},{g},{b},{a})'
|
||||
|
||||
for obj in proxy.objects:
|
||||
obj_colors[obj] = color
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
return obj_colors
|
||||
|
||||
def set_default_color(context_list: List["TraversalContext"]) -> None:
|
||||
"""Get and set the default color."""
|
||||
|
||||
from specklepy.objects.GIS.layers import VectorLayer
|
||||
|
||||
global DEFAULT_COLOR
|
||||
DEFAULT_COLOR = (255 << 24) + (150 << 16) + (150 << 8) + 150
|
||||
|
||||
for item in context_list:
|
||||
# for GIS-commits, use default blue color
|
||||
if isinstance(item.current, VectorLayer) or (item.parent is not None and isinstance(item.parent.current, VectorLayer)):
|
||||
DEFAULT_COLOR = (255 << 24) + (10 << 16) + (132 << 8) + 255 # speckle blue, speckle_blue
|
||||
break
|
||||
|
||||
def getAllParents(tc: "TraversalContext"):
|
||||
|
||||
all_tc = [tc]
|
||||
while True:
|
||||
try:
|
||||
parent = tc.parent
|
||||
if parent:
|
||||
all_tc.append(parent)
|
||||
tc = parent
|
||||
else:
|
||||
break
|
||||
except:
|
||||
break
|
||||
|
||||
return all_tc
|
||||
|
||||
def assign_color(self: "SpeckleProvider", obj_display_tc: "TraversalContext", props: Dict) -> None:
|
||||
"""Get and assign color to feature displayProperties."""
|
||||
|
||||
from specklepy.objects.geometry import Mesh, Brep
|
||||
|
||||
# initialize Speckle Blue color
|
||||
color = DEFAULT_COLOR
|
||||
opacity = None
|
||||
|
||||
obj_display = obj_display_tc.current
|
||||
|
||||
try:
|
||||
# first, choose if get color from the parent obj or displayValue
|
||||
if hasattr(obj_display, 'displayStyle') or hasattr(obj_display, '@displayStyle') or hasattr(obj_display, 'renderMaterial') or hasattr(obj_display, '@renderMaterial'):
|
||||
obj_display = obj_display_tc.current
|
||||
else:
|
||||
# this option will be not very reliable:
|
||||
# there could be different colors for diff displayValues in the list
|
||||
if hasattr(obj_display, 'displayValue'):
|
||||
try:
|
||||
displayVal = obj_display['displayValue']
|
||||
except:
|
||||
displayVal = obj_display.displayValue
|
||||
if isinstance(displayVal, list) and len(displayVal)>0:
|
||||
obj_display = displayVal[0]
|
||||
|
||||
elif hasattr(obj_display, '@displayValue') and isinstance(obj_display['@displayValue'], list) and len(obj_display['@displayValue'])>0:
|
||||
obj_display = obj_display['@displayValue'][0]
|
||||
|
||||
# prioritize renderMaterials for Meshes & Brep
|
||||
if isinstance(obj_display, Mesh) or isinstance(obj_display, Brep):
|
||||
# print(obj_display.get_member_names())
|
||||
if hasattr(obj_display, 'renderMaterial'):
|
||||
try:
|
||||
renderMaterial = obj_display['renderMaterial']
|
||||
except:
|
||||
renderMaterial = obj_display.renderMaterial
|
||||
color = renderMaterial['diffuse']
|
||||
opacity = renderMaterial['opacity']
|
||||
elif hasattr(obj_display, '@renderMaterial'):
|
||||
color = obj_display['@renderMaterial']['diffuse']
|
||||
opacity = obj_display['@renderMaterial']['opacity']
|
||||
|
||||
elif isinstance(obj_display, Mesh) and isinstance(obj_display.colors, List) and len(obj_display.colors)>1:
|
||||
colors_number = 0
|
||||
all_colors = []
|
||||
for c in obj_display.colors:
|
||||
if c not in all_colors:
|
||||
colors_number += 1
|
||||
all_colors.append(c)
|
||||
|
||||
if colors_number>1:
|
||||
all_a = 0
|
||||
all_r = 0
|
||||
all_g = 0
|
||||
all_b = 0
|
||||
for col in all_colors:
|
||||
a, r, g, b = get_r_g_b(col)
|
||||
all_a += a
|
||||
all_r += r
|
||||
all_g += g
|
||||
all_b += b
|
||||
color = (
|
||||
(int(all_a/len(obj_display.colors)) << 24) + (int(all_r/len(obj_display.colors)) << 16)
|
||||
+ (int(all_g/len(obj_display.colors)) << 8) + int(all_b/len(obj_display.colors))
|
||||
)
|
||||
else:
|
||||
color = obj_display.colors[0]
|
||||
|
||||
elif hasattr(obj_display, 'displayStyle'):
|
||||
color = obj_display['displayStyle']['color']
|
||||
elif hasattr(obj_display, '@displayStyle'):
|
||||
color = obj_display['@displayStyle']['color']
|
||||
|
||||
elif hasattr(obj_display, 'displayStyle'):
|
||||
color = obj_display['displayStyle']['color']
|
||||
elif hasattr(obj_display, '@displayStyle'):
|
||||
color = obj_display['@displayStyle']['color']
|
||||
elif hasattr(obj_display, 'renderMaterial'):
|
||||
color = obj_display['renderMaterial']['diffuse']
|
||||
opacity = obj_display['renderMaterial']['opacity']
|
||||
elif hasattr(obj_display, '@renderMaterial'):
|
||||
color = obj_display['@renderMaterial']['diffuse']
|
||||
opacity = obj_display['@renderMaterial']['opacity']
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
a, r, g, b = get_r_g_b(color)
|
||||
if opacity is not None and isinstance(opacity, float):
|
||||
a_test = int(255* opacity)
|
||||
if 0 <= a_test <= 255:
|
||||
a = a_test
|
||||
# hex_color = '#%02x%02x%02x' % (r, g, b)
|
||||
props['color'] = f'rgba({r},{g},{b},{a})'
|
||||
|
||||
# if still not found, check proxies:
|
||||
if color == DEFAULT_COLOR:
|
||||
for tc in getAllParents(obj_display_tc):
|
||||
|
||||
try:
|
||||
color = self.material_color_proxies[tc.current.applicationId]
|
||||
props['color'] = color
|
||||
return
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
color = self.material_color_proxies[obj_display.applicationId]
|
||||
props['color'] = color
|
||||
return
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_r_g_b(rgb: int) -> Tuple[int, int, int]:
|
||||
"""Get R, G, B values from int."""
|
||||
|
||||
r = g = b = 0
|
||||
a = 255
|
||||
try:
|
||||
a = (rgb & 0xFF000000) >> 24
|
||||
r = (rgb & 0xFF0000) >> 16
|
||||
g = (rgb & 0xFF00) >> 8
|
||||
b = rgb & 0xFF
|
||||
except Exception as e:
|
||||
r = g = b = 150
|
||||
a = 255
|
||||
return a, r, g, b
|
||||
|
||||
def assign_display_properties(self: "SpeckleProvider", feature: Dict, f_base: "Base", obj_display_tc: "TraversalContext") -> None:
|
||||
"""Assign displayProperties to the feature."""
|
||||
|
||||
from specklepy.objects.geometry import Mesh, Brep
|
||||
|
||||
assign_color(self, obj_display_tc, feature["displayProperties"])
|
||||
feature["properties"]["color"] = feature["displayProperties"]["color"]
|
||||
|
||||
# other properties for rendering
|
||||
if isinstance(f_base, Mesh) or isinstance(f_base, Brep):
|
||||
feature["displayProperties"]['lineWidth'] = 0.3
|
||||
elif "Line" in feature["geometry"]["type"]:
|
||||
feature["displayProperties"]['lineWidth'] = 3
|
||||
else:
|
||||
feature["displayProperties"]['lineWidth'] = 1
|
||||
|
||||
# if "Point" in feature["geometry"]["type"]:
|
||||
try:
|
||||
feature["displayProperties"]["radius"] = feature["properties"]["weight"]
|
||||
except:
|
||||
feature["displayProperties"]["radius"] = 10
|
||||
@@ -1,236 +0,0 @@
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def initialize_features(self: "SpeckleProvider", all_coords, all_coord_counts, data, context_list, comments: Dict) -> None:
|
||||
"""Create features with props and displayProps, and assign flat list of coordinates."""
|
||||
|
||||
from pygeoapi.provider.speckle_utils.props_utils import assign_props, assign_missing_props
|
||||
from pygeoapi.provider.speckle_utils.converter_utils import assign_geometry
|
||||
from pygeoapi.provider.speckle_utils.display_utils import find_display_obj, assign_display_properties, find_list_of_display_obj
|
||||
|
||||
from specklepy.objects.graph_traversal.traversal import TraversalContext
|
||||
from specklepy.objects.other import Collection
|
||||
|
||||
# print(f"Creating features..")
|
||||
time1 = datetime.now()
|
||||
|
||||
all_props = []
|
||||
feature_count = 0
|
||||
|
||||
if self.requested_data_type != "projectcomments":
|
||||
for item in context_list:
|
||||
|
||||
if item.current.speckle_type.endswith("Collection") or item.current.speckle_type.endswith("Layer") or item.current.speckle_type.endswith("Proxy"):
|
||||
continue
|
||||
|
||||
if feature_count >= self.limit:
|
||||
self.limit_message = f" (feature count limited to {self.limit})"
|
||||
break
|
||||
|
||||
f_base = item.current
|
||||
f_id = item.current.id
|
||||
f_fid = feature_count + 1
|
||||
|
||||
# initialize feature
|
||||
speckle_type = item.current.speckle_type
|
||||
if ":" in speckle_type:
|
||||
speckle_type = speckle_type.split(":")[-1]
|
||||
|
||||
feature: Dict = {
|
||||
"type": "Feature",
|
||||
#"bbox": [-180.0, -90.0, 180.0, 90.0], should not be in degrees
|
||||
"geometry": {},
|
||||
"displayProperties":{
|
||||
"object_type": "geometry",
|
||||
},
|
||||
"properties": {
|
||||
"id": f_id,
|
||||
"FID": f_fid,
|
||||
"speckle_type": speckle_type,
|
||||
},
|
||||
}
|
||||
|
||||
# feature geometry, props and displayProps
|
||||
coords = []
|
||||
coord_counts = []
|
||||
|
||||
if "true" in self.preserve_attributes:
|
||||
obj_display, obj_get_color = find_display_obj(f_base)
|
||||
|
||||
try: # don't break the code if 1 feature fails
|
||||
coords, coord_counts = assign_geometry(self, feature, obj_display)
|
||||
except TypeError as ex:
|
||||
raise ex
|
||||
except Exception as e:
|
||||
print(e)
|
||||
pass
|
||||
|
||||
if len(coords)!=0:
|
||||
all_coords.extend(coords)
|
||||
all_coord_counts.append(coord_counts)
|
||||
|
||||
assign_props(f_base, feature["properties"])
|
||||
# update list of all properties
|
||||
for prop in feature["properties"]:
|
||||
if prop not in all_props:
|
||||
all_props.append(prop)
|
||||
|
||||
obj_get_color_tc = TraversalContext(obj_get_color, "", item)
|
||||
|
||||
assign_display_properties(self, feature, f_base, obj_get_color_tc)
|
||||
feature["max_height"] = max([c[2] for c in coords])
|
||||
feature["bbox"] = get_feature_bbox(coords)
|
||||
data["features"].append(feature)
|
||||
feature_count += 1
|
||||
|
||||
else:
|
||||
list_of_display_obj = find_list_of_display_obj(f_base) # tuple
|
||||
|
||||
for k, vals in enumerate(list_of_display_obj):
|
||||
obj_display, obj_get_color = vals
|
||||
|
||||
f_fid = feature_count + 1
|
||||
feature_new: Dict = {
|
||||
"type": "Feature",
|
||||
#"bbox": [-180.0, -90.0, 180.0, 90.0], should not be in degrees
|
||||
"geometry": {},
|
||||
"displayProperties":{
|
||||
"object_type": "geometry",
|
||||
},
|
||||
"properties": {
|
||||
"id": f_id + "_" + str(k),
|
||||
"FID": f_fid,
|
||||
"speckle_type": item.current.speckle_type.split(":")[-1],
|
||||
},
|
||||
}
|
||||
coords = []
|
||||
coord_counts = []
|
||||
|
||||
try: # don't break the code if 1 feature fails
|
||||
coords, coord_counts = assign_geometry(self, feature_new, obj_display)
|
||||
except TypeError as ex:
|
||||
raise ex
|
||||
except Exception as e:
|
||||
print(e)
|
||||
pass
|
||||
|
||||
if len(coords)!=0:
|
||||
all_coords.extend(coords)
|
||||
all_coord_counts.append(coord_counts)
|
||||
|
||||
obj_get_color_tc = TraversalContext(obj_display, "", item)
|
||||
|
||||
assign_display_properties(self, feature_new, f_base, obj_get_color_tc)
|
||||
feature_new["max_height"] = max([c[2] for c in coords])
|
||||
feature_new["bbox"] = get_feature_bbox(coords)
|
||||
data["features"].append(feature_new)
|
||||
feature_count +=1
|
||||
|
||||
assign_missing_props(data["features"], all_props)
|
||||
else:
|
||||
####################### create comment features
|
||||
for comm_id, comment in comments.items():
|
||||
|
||||
if len(data["comments"]) >= self.limit:
|
||||
self.limit_message = f" (feature count limited to {self.limit})"
|
||||
break
|
||||
|
||||
# initialize comment
|
||||
feature: Dict = {
|
||||
"type": "Feature",
|
||||
"id": comm_id,
|
||||
"geometry": {},
|
||||
"displayProperties": {
|
||||
"object_type": "comment",
|
||||
},
|
||||
"properties": {
|
||||
"messages": [],
|
||||
"text_html": "",
|
||||
"resource_id": "",
|
||||
"all_attachments": []
|
||||
},
|
||||
}
|
||||
|
||||
coords = []
|
||||
coord_counts = []
|
||||
try: # don't break the code if 1 comment fails
|
||||
coords, coord_counts = assign_geometry(self, feature, comment["position"])
|
||||
except Exception as e:
|
||||
print(e)
|
||||
pass
|
||||
|
||||
if len(coords)!=0:
|
||||
all_coords.extend(coords)
|
||||
all_coord_counts.append(coord_counts)
|
||||
assign_comment_data(comment["items"], feature["properties"])
|
||||
data["comments"].append(feature)
|
||||
########################
|
||||
|
||||
if len(data["features"])==0 and len(data["comments"])==0:
|
||||
raise ValueError(f"No supported features of type '{self.requested_data_type}' found. Make sure correct type is requested by adding a URL parameter (e.g. '&dataType=points').")
|
||||
|
||||
time2 = datetime.now()
|
||||
|
||||
time_operation = (time2-time1).total_seconds()
|
||||
self.times["time_creating_features"] = time_operation
|
||||
# print(f"Creating features time: {time_operation}")
|
||||
|
||||
def get_feature_bbox(coords) -> List[float]:
|
||||
"""Get min max coordinates of the feature."""
|
||||
|
||||
x0 = min([c[0] for c in coords])
|
||||
x1 = max([c[0] for c in coords])
|
||||
y0 = min([c[1] for c in coords])
|
||||
y1 = max([c[1] for c in coords])
|
||||
|
||||
return [x0, y0, x1, y1]
|
||||
|
||||
def assign_comment_data(comments, properties):
|
||||
"""Create html text to display for the thread."""
|
||||
|
||||
for item in comments:
|
||||
r'''
|
||||
"author": author_name,
|
||||
"date": created_date, # e.g. 2024-08-25T13:52:50.562Z
|
||||
"text": raw_text,
|
||||
"attachments": [attachments_paths],
|
||||
"resource_id": string
|
||||
'''
|
||||
try:
|
||||
formatted_time = datetime.strptime(item["date"].replace("T", " ").replace("Z","").split(".")[0], '%Y-%m-%d %H:%M:%S')
|
||||
except:
|
||||
formatted_time = item["date"]
|
||||
|
||||
properties["messages"].append(f"Author: {item["author"]}, created: {formatted_time}, text: {item["text"]}, attachments: {[img for img in item["attachments"]]}")
|
||||
|
||||
try:
|
||||
properties["resource_id"] = item["resource_id"]
|
||||
except:
|
||||
pass # will not be available for replies, only first comment
|
||||
|
||||
properties["text_html"] += f"<b>{item["author"]}</b> at {formatted_time}: <br>   {item["text"]}<br>"
|
||||
for img in item["attachments"]:
|
||||
properties["text_html"] += f" <i>   '{img}'</i> <br>"
|
||||
properties["all_attachments"].append(img)
|
||||
|
||||
properties["text_html"] += "<br>"
|
||||
|
||||
#properties["author"] = comment["author"]
|
||||
#properties["date"] = comment["date"]
|
||||
#properties["text"] = comment["text"]
|
||||
#properties["attachments"] = comment["attachments"]
|
||||
|
||||
|
||||
|
||||
def create_features(self: "SpeckleProvider", context_list: List["TraversalContext"], comments: Dict, data: Dict) -> None:
|
||||
"""Create features from the list of traversal context."""
|
||||
|
||||
from pygeoapi.provider.speckle_utils.coords_utils import reproject_bulk
|
||||
|
||||
all_coords = []
|
||||
all_coord_counts = []
|
||||
initialize_features(self, all_coords, all_coord_counts, data, context_list, comments)
|
||||
all_features = data["features"] + data["comments"]
|
||||
reproject_bulk(self, all_coords, all_coord_counts, [f["geometry"] for f in all_features])
|
||||
@@ -1,79 +0,0 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
_user_data_env_var = "SPECKLE_USERDATA_PATH"
|
||||
_application_name = "Speckle"
|
||||
|
||||
def user_application_data_path() -> "Path":
|
||||
"""Get the platform specific user configuration folder path"""
|
||||
from pathlib import Path
|
||||
|
||||
path_override = _path()
|
||||
if path_override:
|
||||
return path_override
|
||||
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
app_data_path = os.getenv("APPDATA")
|
||||
if not app_data_path:
|
||||
raise Exception("Cannot get appdata path from environment.")
|
||||
return Path(app_data_path)
|
||||
else:
|
||||
# try getting the standard XDG_DATA_HOME value
|
||||
# as that is used as an override
|
||||
app_data_path = os.getenv("XDG_DATA_HOME")
|
||||
if app_data_path:
|
||||
return Path(app_data_path)
|
||||
else:
|
||||
return ensure_folder_exists(Path.home(), ".config")
|
||||
except Exception as ex:
|
||||
raise Exception("Failed to initialize user application data path.", ex)
|
||||
|
||||
def ensure_folder_exists(base_path: "Path", folder_name: str) -> "Path":
|
||||
from pathlib import Path
|
||||
|
||||
path = base_path.joinpath(folder_name)
|
||||
path.mkdir(exist_ok=True, parents=True)
|
||||
return path
|
||||
|
||||
def _path() -> Optional["Path"]:
|
||||
from pathlib import Path
|
||||
|
||||
"""Read the user data path override setting."""
|
||||
path_override = os.environ.get(_user_data_env_var)
|
||||
if path_override:
|
||||
return Path(path_override)
|
||||
return None
|
||||
|
||||
def connector_installation_path(host_application: str) -> "Path":
|
||||
connector_installation_path = user_speckle_connector_installation_path(
|
||||
host_application
|
||||
)
|
||||
connector_installation_path.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# set user modules path at beginning of paths for earlier hit
|
||||
if sys.path[0] != connector_installation_path:
|
||||
sys.path.insert(0, str(connector_installation_path))
|
||||
|
||||
# print(f"Using connector installation path {connector_installation_path}")
|
||||
return connector_installation_path
|
||||
|
||||
def user_speckle_connector_installation_path(host_application: str) -> "Path":
|
||||
"""
|
||||
Gets a connector specific installation folder.
|
||||
In this folder we can put our connector installation and all python packages.
|
||||
"""
|
||||
return ensure_folder_exists(
|
||||
ensure_folder_exists(
|
||||
user_speckle_folder_path(), "connector_installations"
|
||||
),
|
||||
host_application,
|
||||
)
|
||||
|
||||
def user_speckle_folder_path() -> "Path":
|
||||
"""Get the folder where the user's Speckle data should be stored."""
|
||||
return ensure_folder_exists(
|
||||
user_application_data_path(), _application_name
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
COUNTRY_CODES = ["ru"]
|
||||
STATES = ['Автономна Республіка Крим', 'Севастополь', 'Донецька область', 'Луганська область']
|
||||
POSTCODES = [str(i) for i in range(95000,99999)]
|
||||
@@ -1,13 +0,0 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from specklepy.objects.base import Base
|
||||
|
||||
|
||||
class GisFeature(
|
||||
Base, speckle_type="Objects.GIS.GisFeature", detachable={"displayValue"}
|
||||
):
|
||||
"""GIS Feature"""
|
||||
|
||||
geometry: Optional[List[Base]] = None
|
||||
attributes: Base
|
||||
displayValue: Optional[List[Base]] = None
|
||||
@@ -1,439 +0,0 @@
|
||||
import hashlib
|
||||
import re
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from uuid import uuid4
|
||||
from warnings import warn
|
||||
|
||||
import ujson
|
||||
|
||||
# import for serialization
|
||||
from specklepy.logging.exceptions import SpeckleException, SpeckleWarning
|
||||
from specklepy.objects.base import Base, DataChunk
|
||||
from specklepy.transports.abstract_transport import AbstractTransport
|
||||
|
||||
PRIMITIVES = (int, float, str, bool)
|
||||
|
||||
|
||||
def hash_obj(obj: Any) -> str:
|
||||
return hashlib.sha256(ujson.dumps(obj).encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
def safe_json_loads(obj: str, obj_id=None) -> Any:
|
||||
try:
|
||||
return ujson.loads(obj)
|
||||
except ValueError as err:
|
||||
import json
|
||||
|
||||
warn(
|
||||
f"Failed to deserialise object (id: {obj_id}). This is likely a ujson big"
|
||||
f" int error - falling back to json. \nError: {err}",
|
||||
SpeckleWarning,
|
||||
)
|
||||
return json.loads(obj)
|
||||
|
||||
|
||||
class BaseObjectSerializer:
|
||||
read_transport: AbstractTransport
|
||||
write_transports: List[AbstractTransport]
|
||||
detach_lineage: List[bool] # tracks depth and whether or not to detach
|
||||
lineage: List[str] # keeps track of hash chain through the object tree
|
||||
family_tree: Dict[str, Dict[str, int]]
|
||||
closure_table: Dict[str, Dict[str, int]]
|
||||
deserialized: Dict[
|
||||
str, Base
|
||||
] # holds deserialized objects so objects with same id return the same instance
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
write_transports: Optional[List[AbstractTransport]] = None,
|
||||
read_transport: Optional[AbstractTransport] = None,
|
||||
) -> None:
|
||||
self.write_transports = write_transports or []
|
||||
self.read_transport = read_transport
|
||||
self.detach_lineage = []
|
||||
self.lineage = []
|
||||
self.family_tree = {}
|
||||
self.closure_table = {}
|
||||
self.deserialized = {}
|
||||
|
||||
def write_json(self, base: Base):
|
||||
"""Serializes a given base object into a json string
|
||||
Arguments:
|
||||
base {Base} -- the base object to be decomposed and serialized
|
||||
|
||||
Returns:
|
||||
(str, str) -- a tuple containing the object id of the base object and
|
||||
the serialized object string
|
||||
"""
|
||||
|
||||
obj_id, obj = self.traverse_base(base)
|
||||
|
||||
return obj_id, ujson.dumps(obj)
|
||||
|
||||
def traverse_base(self, base: Base) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Decomposes the given base object and builds a serializable dictionary
|
||||
|
||||
Arguments:
|
||||
base {Base} -- the base object to be decomposed and serialized
|
||||
|
||||
Returns:
|
||||
(str, dict) -- a tuple containing the object id of the base object and
|
||||
the constructed serializable dictionary
|
||||
"""
|
||||
self.__reset_writer()
|
||||
|
||||
if self.write_transports:
|
||||
for wt in self.write_transports:
|
||||
wt.begin_write()
|
||||
|
||||
obj_id, obj = self._traverse_base(base)
|
||||
|
||||
if self.write_transports:
|
||||
for wt in self.write_transports:
|
||||
wt.end_write()
|
||||
|
||||
return obj_id, obj
|
||||
|
||||
def _traverse_base(self, base: Base) -> Tuple[str, Dict]:
|
||||
if not self.detach_lineage:
|
||||
self.detach_lineage = [True]
|
||||
|
||||
self.lineage.append(uuid4().hex)
|
||||
object_builder = {"id": "", "speckle_type": "Base", "totalChildrenCount": 0}
|
||||
object_builder.update(speckle_type=base.speckle_type)
|
||||
obj, props = base, base.get_serializable_attributes()
|
||||
|
||||
while props:
|
||||
prop = props.pop(0)
|
||||
value = getattr(obj, prop, None)
|
||||
chunkable = False
|
||||
detach = False
|
||||
|
||||
# skip props marked to be ignored with "__" or "_"
|
||||
if prop.startswith(("__", "_")):
|
||||
continue
|
||||
|
||||
# don't prepopulate id as this will mess up hashing
|
||||
if prop == "id":
|
||||
continue
|
||||
|
||||
# only bother with chunking and detaching if there is a write transport
|
||||
if self.write_transports:
|
||||
dynamic_chunk_match = prop.startswith("@") and re.match(
|
||||
r"^@\((\d*)\)", prop
|
||||
)
|
||||
if dynamic_chunk_match:
|
||||
chunk_size = dynamic_chunk_match.groups()[0]
|
||||
base._chunkable[prop] = (
|
||||
int(chunk_size) if chunk_size else base._chunk_size_default
|
||||
)
|
||||
|
||||
chunkable = prop in base._chunkable
|
||||
detach = bool(
|
||||
prop.startswith("@") or prop in base._detachable or chunkable
|
||||
)
|
||||
|
||||
# 1. handle None and primitives (ints, floats, strings, and bools)
|
||||
if value is None or isinstance(value, PRIMITIVES):
|
||||
object_builder[prop] = value
|
||||
continue
|
||||
|
||||
# NOTE: for dynamic props, this won't be re-serialised as an enum but as an int
|
||||
if isinstance(value, Enum):
|
||||
object_builder[prop] = value.value
|
||||
continue
|
||||
|
||||
# 2. handle Base objects
|
||||
elif isinstance(value, Base):
|
||||
child_obj = self.traverse_value(value, detach=detach)
|
||||
if detach and self.write_transports:
|
||||
ref_id = child_obj["id"]
|
||||
object_builder[prop] = self.detach_helper(ref_id=ref_id)
|
||||
else:
|
||||
object_builder[prop] = child_obj
|
||||
|
||||
# 3. handle chunkable props
|
||||
elif chunkable and self.write_transports:
|
||||
chunks = []
|
||||
max_size = base._chunkable[prop]
|
||||
chunk = DataChunk()
|
||||
for count, item in enumerate(value):
|
||||
if count and count % max_size == 0:
|
||||
chunks.append(chunk)
|
||||
chunk = DataChunk()
|
||||
chunk.data.append(item)
|
||||
chunks.append(chunk)
|
||||
|
||||
chunk_refs = []
|
||||
for c in chunks:
|
||||
self.detach_lineage.append(detach)
|
||||
ref_id, _ = self._traverse_base(c)
|
||||
ref_obj = self.detach_helper(ref_id=ref_id)
|
||||
chunk_refs.append(ref_obj)
|
||||
object_builder[prop] = chunk_refs
|
||||
|
||||
# 4. handle all other cases
|
||||
else:
|
||||
child_obj = self.traverse_value(value, detach)
|
||||
object_builder[prop] = child_obj
|
||||
|
||||
closure = {}
|
||||
# add closures & children count to the object
|
||||
detached = self.detach_lineage.pop()
|
||||
if self.lineage[-1] in self.family_tree:
|
||||
closure = {
|
||||
ref: depth - len(self.detach_lineage)
|
||||
for ref, depth in self.family_tree[self.lineage[-1]].items()
|
||||
}
|
||||
object_builder["totalChildrenCount"] = len(closure)
|
||||
|
||||
obj_id = hash_obj(object_builder)
|
||||
|
||||
object_builder["id"] = obj_id
|
||||
if closure:
|
||||
object_builder["__closure"] = self.closure_table[obj_id] = closure
|
||||
|
||||
# write detached or root objects to transports
|
||||
if detached and self.write_transports:
|
||||
for t in self.write_transports:
|
||||
t.save_object(id=obj_id, serialized_object=ujson.dumps(object_builder))
|
||||
|
||||
del self.lineage[-1]
|
||||
|
||||
return obj_id, object_builder
|
||||
|
||||
def traverse_value(self, obj: Any, detach: bool = False) -> Any:
|
||||
"""Decomposes a given object and constructs a serializable object or dictionary
|
||||
|
||||
Arguments:
|
||||
obj {Any} -- the value to decompose
|
||||
|
||||
Returns:
|
||||
Any -- a serializable version of the given object
|
||||
"""
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, PRIMITIVES):
|
||||
return obj
|
||||
|
||||
# NOTE: for dynamic props, this won't be re-serialised as an enum but as an int
|
||||
if isinstance(obj, Enum):
|
||||
return obj.value
|
||||
|
||||
elif isinstance(obj, (list, tuple, set)):
|
||||
if not detach:
|
||||
return [self.traverse_value(o) for o in obj]
|
||||
|
||||
detached_list = []
|
||||
for o in obj:
|
||||
if isinstance(o, Base):
|
||||
self.detach_lineage.append(detach)
|
||||
ref_id, _ = self._traverse_base(o)
|
||||
detached_list.append(self.detach_helper(ref_id=ref_id))
|
||||
else:
|
||||
detached_list.append(self.traverse_value(o, detach))
|
||||
return detached_list
|
||||
|
||||
elif isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if isinstance(v, PRIMITIVES) or v is None:
|
||||
continue
|
||||
else:
|
||||
obj[k] = self.traverse_value(v)
|
||||
return obj
|
||||
|
||||
elif isinstance(obj, Base):
|
||||
self.detach_lineage.append(detach)
|
||||
_, base_obj = self._traverse_base(obj)
|
||||
return base_obj
|
||||
|
||||
else:
|
||||
try:
|
||||
return obj.dict()
|
||||
except Exception:
|
||||
warn(
|
||||
f"Failed to handle {type(obj)} in"
|
||||
" `BaseObjectSerializer.traverse_value`",
|
||||
SpeckleWarning,
|
||||
)
|
||||
|
||||
return str(obj)
|
||||
|
||||
def detach_helper(self, ref_id: str) -> Dict[str, str]:
|
||||
"""
|
||||
Helper to keep track of detached objects and their depth in the family tree
|
||||
and create reference objects to place in the parent object
|
||||
|
||||
Arguments:
|
||||
ref_id {str} -- the id of the fully traversed object
|
||||
|
||||
Returns:
|
||||
dict -- a reference object to be inserted into the given object's parent
|
||||
"""
|
||||
|
||||
for parent in self.lineage:
|
||||
if parent not in self.family_tree:
|
||||
self.family_tree[parent] = {}
|
||||
if ref_id not in self.family_tree[parent] or self.family_tree[parent][
|
||||
ref_id
|
||||
] > len(self.detach_lineage):
|
||||
self.family_tree[parent][ref_id] = len(self.detach_lineage)
|
||||
|
||||
return {
|
||||
"referencedId": ref_id,
|
||||
"speckle_type": "reference",
|
||||
}
|
||||
|
||||
def __reset_writer(self) -> None:
|
||||
"""
|
||||
Reinitializes the lineage, and other variables that get used during the json
|
||||
writing process
|
||||
"""
|
||||
self.detach_lineage = [True]
|
||||
self.lineage = []
|
||||
self.family_tree = {}
|
||||
self.closure_table = {}
|
||||
|
||||
def read_json(self, obj_string: str) -> Base:
|
||||
"""Recomposes a Base object from the string representation of the object
|
||||
|
||||
Arguments:
|
||||
obj_string {str} -- the string representation of the object
|
||||
|
||||
Returns:
|
||||
Base -- the base object with all it's children attached
|
||||
"""
|
||||
if not obj_string:
|
||||
return None
|
||||
|
||||
self.deserialized = {}
|
||||
obj = safe_json_loads(obj_string)
|
||||
return self.recompose_base(obj=obj)
|
||||
|
||||
def recompose_base(self, obj: dict) -> Base:
|
||||
"""Steps through a base object dictionary and recomposes the base object
|
||||
|
||||
Arguments:
|
||||
obj {dict} -- the dictionary representation of the object
|
||||
|
||||
Returns:
|
||||
Base -- the base object with all its children attached
|
||||
"""
|
||||
# make sure an obj was passed and create dict if string was somehow passed
|
||||
if not obj:
|
||||
return
|
||||
if isinstance(obj, str):
|
||||
obj = safe_json_loads(obj)
|
||||
|
||||
if "id" in obj and obj["id"] in self.deserialized:
|
||||
return self.deserialized[obj["id"]]
|
||||
|
||||
if "speckle_type" in obj and obj["speckle_type"] == "reference":
|
||||
obj = self.get_child(obj=obj)
|
||||
|
||||
speckle_type = obj.get("speckle_type")
|
||||
# if speckle type is not in the object definition, it is treated as a dict
|
||||
if not speckle_type:
|
||||
return obj
|
||||
|
||||
# get the registered type from base register.
|
||||
object_type = Base.get_registered_type(speckle_type)
|
||||
|
||||
# initialise the base object using `speckle_type` fall back to base if needed
|
||||
base = object_type() if object_type else Base.of_type(speckle_type=speckle_type)
|
||||
# get total children count
|
||||
if "__closure" in obj:
|
||||
if not self.read_transport:
|
||||
raise SpeckleException(
|
||||
message="Cannot resolve reference - no read transport is defined"
|
||||
)
|
||||
closure = obj.pop("__closure")
|
||||
base.totalChildrenCount = len(closure)
|
||||
|
||||
for prop, value in obj.items():
|
||||
# 1. handle primitives (ints, floats, strings, and bools) or None
|
||||
if isinstance(value, PRIMITIVES) or value is None:
|
||||
base.__setattr__(prop, value)
|
||||
continue
|
||||
|
||||
# 2. handle referenced child objects
|
||||
elif "referencedId" in value:
|
||||
ref_id = value["referencedId"]
|
||||
ref_obj_str = self.read_transport.get_object(id=ref_id)
|
||||
if ref_obj_str:
|
||||
ref_obj = safe_json_loads(ref_obj_str, ref_id)
|
||||
base.__setattr__(prop, self.recompose_base(obj=ref_obj))
|
||||
else:
|
||||
warnings.warn(
|
||||
f"Could not find the referenced child object of id `{ref_id}`"
|
||||
f" in the given read transport: {self.read_transport.name}",
|
||||
SpeckleWarning,
|
||||
)
|
||||
base.__setattr__(prop, self.handle_value(value))
|
||||
|
||||
# 3. handle all other cases (base objects, lists, and dicts)
|
||||
else:
|
||||
base.__setattr__(prop, self.handle_value(value))
|
||||
|
||||
if "id" in obj:
|
||||
self.deserialized[obj["id"]] = base
|
||||
|
||||
return base
|
||||
|
||||
def handle_value(self, obj: Any):
|
||||
"""Helper for recomposing a base object by handling the dictionary
|
||||
representation's values
|
||||
|
||||
Arguments:
|
||||
obj {Any} -- a value from the base object dictionary
|
||||
|
||||
Returns:
|
||||
Any -- the handled value (primitive, list, dictionary, or Base)
|
||||
"""
|
||||
if not obj:
|
||||
return obj
|
||||
|
||||
if isinstance(obj, PRIMITIVES):
|
||||
return obj
|
||||
|
||||
# lists (regular and chunked)
|
||||
if isinstance(obj, list):
|
||||
obj_list = [self.handle_value(o) for o in obj]
|
||||
if (
|
||||
hasattr(obj_list[0], "speckle_type")
|
||||
and "DataChunk" in obj_list[0].speckle_type
|
||||
):
|
||||
# handle chunked lists
|
||||
data = []
|
||||
for o in obj_list:
|
||||
data.extend(o.data)
|
||||
return data
|
||||
return obj_list
|
||||
|
||||
# bases
|
||||
if isinstance(obj, dict) and "speckle_type" in obj:
|
||||
return self.recompose_base(obj=obj)
|
||||
|
||||
# dictionaries
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if isinstance(v, PRIMITIVES):
|
||||
continue
|
||||
else:
|
||||
obj[k] = self.handle_value(v)
|
||||
return obj
|
||||
|
||||
def get_child(self, obj: Dict):
|
||||
ref_id = obj["referencedId"]
|
||||
ref_obj_str = self.read_transport.get_object(id=ref_id)
|
||||
if not ref_obj_str:
|
||||
warnings.warn(
|
||||
f"Could not find the referenced child object of id `{ref_id}` in the"
|
||||
f" given read transport: {self.read_transport.name}",
|
||||
SpeckleWarning,
|
||||
)
|
||||
return obj
|
||||
|
||||
return safe_json_loads(ref_obj_str, ref_id)
|
||||
@@ -1,140 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import pygeoapi
|
||||
|
||||
|
||||
def get_specklepy_path():
|
||||
import specklepy
|
||||
|
||||
return Path(specklepy.__file__).parent
|
||||
|
||||
def get_pygeoapi_path():
|
||||
|
||||
return Path(pygeoapi.__file__).parent
|
||||
|
||||
|
||||
def get_credentials_path():
|
||||
specklepy_path = get_specklepy_path()
|
||||
credentials_path = Path(specklepy_path, "core", "api", "credentials.py")
|
||||
|
||||
return str(credentials_path)
|
||||
|
||||
def get_transport_path():
|
||||
specklepy_path = get_specklepy_path()
|
||||
credentials_path = Path(specklepy_path, "transports", "server", "server.py")
|
||||
|
||||
return str(credentials_path)
|
||||
|
||||
def get_transport_path_src():
|
||||
credentials_path = Path(get_pygeoapi_path(), "provider", "speckle_utils", "patch", "server.py")
|
||||
|
||||
return str(credentials_path)
|
||||
|
||||
def get_serializer_path():
|
||||
specklepy_path = get_specklepy_path()
|
||||
credentials_path = Path(specklepy_path, "serialization", "base_object_serializer.py")
|
||||
|
||||
return str(credentials_path)
|
||||
|
||||
def get_serializer_path_src():
|
||||
credentials_path = Path(get_pygeoapi_path(), "provider", "speckle_utils", "patch", "base_object_serializer.py")
|
||||
|
||||
return str(credentials_path)
|
||||
|
||||
def get_gis_feature_path_src():
|
||||
credentials_path = Path(get_pygeoapi_path(), "provider", "speckle_utils", "patch", "GisFeature.py")
|
||||
|
||||
return str(credentials_path)
|
||||
|
||||
def get_gis_feature_path_dst():
|
||||
specklepy_path = get_specklepy_path()
|
||||
credentials_path = Path(specklepy_path, "objects", "GIS", "GisFeature.py")
|
||||
|
||||
return str(credentials_path)
|
||||
|
||||
def patch_credentials():
|
||||
"""Patches the installer with the correct connector version and specklepy version"""
|
||||
|
||||
file_path = get_credentials_path()
|
||||
|
||||
with open(file_path, "r") as file:
|
||||
lines = file.readlines()
|
||||
new_lines = []
|
||||
for i, line in enumerate(lines):
|
||||
if "Account.model_validate_json" in line:
|
||||
line = line.replace("Account.model_validate_json", "Account.parse_raw")
|
||||
new_lines.append(line)
|
||||
file.close()
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
file.writelines(new_lines)
|
||||
file.close()
|
||||
|
||||
def patch_transport():
|
||||
"""Patches the installer with the correct connector version and specklepy version"""
|
||||
|
||||
server_data = get_transport_path_src()
|
||||
file_path = get_transport_path()
|
||||
|
||||
with open(server_data, "r") as file:
|
||||
lines = file.readlines()
|
||||
file.close()
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
file.writelines(lines)
|
||||
file.close()
|
||||
|
||||
def patch_serializer():
|
||||
"""Patches the installer with the correct connector version and specklepy version"""
|
||||
|
||||
server_data = get_serializer_path_src()
|
||||
file_path = get_serializer_path()
|
||||
|
||||
with open(server_data, "r") as file:
|
||||
lines = file.readlines()
|
||||
file.close()
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
file.writelines(lines)
|
||||
file.close()
|
||||
|
||||
def complete_patch():
|
||||
"""Patches the installer with the correct connector version and specklepy version"""
|
||||
|
||||
# check file 1
|
||||
file_path = get_transport_path()
|
||||
with open(file_path, "r") as file:
|
||||
lines = file.readlines()
|
||||
file.close()
|
||||
|
||||
if len(lines) < 184:
|
||||
return False
|
||||
|
||||
# check file 1
|
||||
file_path = get_serializer_path()
|
||||
with open(file_path, "r") as file:
|
||||
lines = file.readlines()
|
||||
file.close()
|
||||
|
||||
if len(lines) < 443:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def copy_gis_feature():
|
||||
shutil.copyfile(get_gis_feature_path_src(), get_gis_feature_path_dst())
|
||||
|
||||
def patch_specklepy():
|
||||
|
||||
#if complete_patch():
|
||||
# return
|
||||
|
||||
patch_credentials()
|
||||
copy_gis_feature()
|
||||
patch_transport()
|
||||
patch_serializer()
|
||||
|
||||
if __name__ == "__main__":
|
||||
patch_specklepy()
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
from warnings import warn
|
||||
|
||||
import requests
|
||||
|
||||
from specklepy.core.api.client import SpeckleClient
|
||||
from specklepy.core.api.credentials import Account, get_account_from_token
|
||||
from specklepy.logging.exceptions import SpeckleException, SpeckleWarning
|
||||
from specklepy.transports.abstract_transport import AbstractTransport
|
||||
|
||||
from .batch_sender import BatchSender
|
||||
|
||||
|
||||
class ServerTransport(AbstractTransport):
|
||||
"""
|
||||
The `ServerTransport` is the vehicle through which you transport objects to and
|
||||
from a Speckle Server. Provide it to `operations.send()` or `operations.receive()`.
|
||||
|
||||
The `ServerTransport` can be authenticated two different ways:
|
||||
1. by providing a `SpeckleClient`
|
||||
2. by providing an `Account`
|
||||
3. by providing a `token` and `url`
|
||||
|
||||
```py
|
||||
from specklepy.api import operations
|
||||
from specklepy.transports.server import ServerTransport
|
||||
|
||||
# here's the data you want to send
|
||||
block = Block(length=2, height=4)
|
||||
|
||||
# next create the server transport - this is the vehicle through which
|
||||
# you will send and receive
|
||||
transport = ServerTransport(stream_id=new_stream_id, client=client)
|
||||
|
||||
# this serialises the block and sends it to the transport
|
||||
hash = operations.send(base=block, transports=[transport])
|
||||
|
||||
# you can now create a commit on your stream with this object
|
||||
commit_id = client.commit.create(
|
||||
stream_id=new_stream_id,
|
||||
obj_id=hash,
|
||||
message="this is a block I made in speckle-py",
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream_id: str,
|
||||
client: Optional[SpeckleClient] = None,
|
||||
account: Optional[Account] = None,
|
||||
token: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
name: str = "RemoteTransport",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
if client is None and account is None and token is None and url is None:
|
||||
raise SpeckleException(
|
||||
"You must provide either a client or a token and url to construct a"
|
||||
" ServerTransport."
|
||||
)
|
||||
|
||||
self._name = name
|
||||
self.account = None
|
||||
self.saved_obj_count = 0
|
||||
if account:
|
||||
self.account = account
|
||||
url = account.serverInfo.url
|
||||
elif client:
|
||||
url = client.url
|
||||
if not client.account.token:
|
||||
warn(
|
||||
SpeckleWarning(
|
||||
"Unauthenticated Speckle Client provided to Server Transport"
|
||||
f" for {url}. Receiving from private streams will fail."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.account = client.account
|
||||
else:
|
||||
self.account = get_account_from_token(token, url)
|
||||
|
||||
self.stream_id = stream_id
|
||||
self.url = url
|
||||
|
||||
self.session = requests.Session()
|
||||
|
||||
if self.account.token is not None:
|
||||
self._batch_sender = BatchSender(
|
||||
self.url, self.stream_id, self.account.token, max_batch_size_mb=1
|
||||
)
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {self.account.token}",
|
||||
"Accept": "text/plain",
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def begin_write(self) -> None:
|
||||
self.saved_obj_count = 0
|
||||
|
||||
def end_write(self) -> None:
|
||||
self._batch_sender.flush()
|
||||
|
||||
def save_object(self, id: str, serialized_object: str) -> None:
|
||||
self._batch_sender.send_object(id, serialized_object)
|
||||
|
||||
def save_object_from_transport(
|
||||
self, id: str, source_transport: AbstractTransport
|
||||
) -> None:
|
||||
obj_string = source_transport.get_object(id=id)
|
||||
self.save_object(id=id, serialized_object=obj_string)
|
||||
|
||||
def get_object(self, id: str) -> str:
|
||||
# endpoint = f"{self.url}/objects/{self.stream_id}/{id}/single"
|
||||
# r = self.session.get(endpoint, stream=True)
|
||||
|
||||
# _, obj = next(r.iter_lines().decode("utf-8")).split("\t")
|
||||
|
||||
# return obj
|
||||
|
||||
raise SpeckleException(
|
||||
"Getting a single object using `ServerTransport.get_object()` is not"
|
||||
" implemented. To get an object from the server, please use the"
|
||||
" `SpeckleClient.object.get()` route",
|
||||
NotImplementedError(),
|
||||
)
|
||||
|
||||
def has_objects(self, id_list: List[str]) -> Dict[str, bool]:
|
||||
return {id: False for id in id_list}
|
||||
|
||||
def copy_object_and_children(
|
||||
self, id: str, target_transport: AbstractTransport
|
||||
) -> str:
|
||||
endpoint = f"{self.url}/objects/{self.stream_id}/{id}/single"
|
||||
r = self.session.get(endpoint)
|
||||
r.encoding = "utf-8"
|
||||
|
||||
if r.status_code != 200:
|
||||
raise SpeckleException(
|
||||
f"Can't get object {self.stream_id}/{id}: HTTP error"
|
||||
f" {r.status_code} ({r.text[:1000]})"
|
||||
)
|
||||
root_obj_serialized = r.text
|
||||
root_obj = json.loads(root_obj_serialized)
|
||||
closures = root_obj.get("__closure", {})
|
||||
|
||||
# Check which children are not already in the target transport
|
||||
children_ids = list(closures.keys())
|
||||
children_found_map = target_transport.has_objects(children_ids)
|
||||
new_children_ids = [
|
||||
id for id in children_found_map if not children_found_map[id]
|
||||
]
|
||||
|
||||
# save headers and assign them back later
|
||||
headers = self.session.headers
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Accept": "text/plain",
|
||||
}
|
||||
)
|
||||
|
||||
# Get the new children
|
||||
endpoint = f"{self.url}/api/getobjects/{self.stream_id}"
|
||||
r = self.session.post(
|
||||
endpoint, data={"objects": json.dumps(new_children_ids)}, stream=True
|
||||
)
|
||||
r.encoding = "utf-8"
|
||||
lines = r.iter_lines(decode_unicode=True)
|
||||
self.session.headers = headers # return previous headers
|
||||
|
||||
# iter through returned objects saving them as we go
|
||||
target_transport.begin_write()
|
||||
for line in lines:
|
||||
if line:
|
||||
hash, obj = line.split("\t")
|
||||
target_transport.save_object(hash, obj)
|
||||
|
||||
target_transport.save_object(id, root_obj_serialized)
|
||||
target_transport.end_write()
|
||||
|
||||
return root_obj_serialized
|
||||
@@ -1,103 +0,0 @@
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def assign_props(obj: "Base", props: Dict):
|
||||
"""Assign properties to the feature from Base object."""
|
||||
|
||||
from specklepy.objects.geometry import Base
|
||||
from specklepy.objects.other import RevitParameter
|
||||
|
||||
all_prop_names = obj.get_member_names()
|
||||
dynamic_prop_names = obj.get_dynamic_member_names()
|
||||
typed_prop_names = obj.get_typed_member_names()
|
||||
|
||||
# check if GIS object
|
||||
if "attributes" in all_prop_names and isinstance(obj["attributes"], Base):
|
||||
all_prop_names = obj["attributes"].get_dynamic_member_names()
|
||||
for prop_name in all_prop_names:
|
||||
|
||||
value = getattr(obj["attributes"], prop_name)
|
||||
|
||||
if (prop_name
|
||||
in [
|
||||
"geometry",
|
||||
"Speckle_ID",
|
||||
"id",
|
||||
]
|
||||
):
|
||||
pass
|
||||
else:
|
||||
if (
|
||||
isinstance(value, Base)
|
||||
or isinstance(value, List)
|
||||
or isinstance(value, Dict)
|
||||
):
|
||||
props[prop_name] = str(value)
|
||||
else:
|
||||
props[prop_name] = value
|
||||
return
|
||||
|
||||
# if Rhino:
|
||||
elif "userStrings" in dynamic_prop_names and isinstance(obj["userStrings"], Base):
|
||||
all_prop_names = obj["userStrings"].get_dynamic_member_names()
|
||||
|
||||
for prop_name in all_prop_names:
|
||||
|
||||
if prop_name in ["id"]:
|
||||
continue
|
||||
|
||||
value = getattr(obj["userStrings"], prop_name)
|
||||
if not isinstance(value, str):
|
||||
props[prop_name] = str(value)
|
||||
else:
|
||||
props[prop_name] = value
|
||||
return
|
||||
|
||||
for prop_name in obj.get_dynamic_member_names():
|
||||
if (
|
||||
prop_name
|
||||
in [
|
||||
"displayValue",
|
||||
"displayStyle",
|
||||
"renderMaterial",
|
||||
"revitLinkedModelPath",
|
||||
"id",
|
||||
]
|
||||
):
|
||||
pass
|
||||
else:
|
||||
value = getattr(obj, prop_name)
|
||||
if (
|
||||
isinstance(value, Base)
|
||||
or isinstance(value, List)
|
||||
or isinstance(value, Dict)
|
||||
):
|
||||
props[prop_name] = str(value)
|
||||
else:
|
||||
props[prop_name] = value
|
||||
|
||||
# if Revit:
|
||||
if "parameters" in all_prop_names and isinstance(obj.parameters, Base):
|
||||
for prop_name in obj.parameters.get_dynamic_member_names():
|
||||
if prop_name in ["id","revitLinkedModelPath"]:
|
||||
continue
|
||||
|
||||
param = getattr(obj.parameters, prop_name)
|
||||
if isinstance(param, RevitParameter):
|
||||
|
||||
if not isinstance(param.value, str):
|
||||
props[prop_name] = str(param.value)
|
||||
else:
|
||||
props[prop_name] = param.value
|
||||
# add after dynamic parameters
|
||||
|
||||
|
||||
def assign_missing_props(features: Dict, all_props: List[str]) -> None:
|
||||
"""Assign NA values to missing properties."""
|
||||
|
||||
# assign all props to all features
|
||||
for feat in features:
|
||||
for prop in all_props:
|
||||
if prop not in list(feat["properties"].keys()):
|
||||
feat["properties"][prop] = "N/A"
|
||||
@@ -1,234 +0,0 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import pygeoapi
|
||||
|
||||
|
||||
def get_stream_branch(self: "SpeckleProvider", client: "SpeckleClient", wrapper: "StreamWrapper") -> Tuple:
|
||||
"""Get stream and branch from the server."""
|
||||
|
||||
from specklepy.logging.exceptions import SpeckleException
|
||||
|
||||
branch = None
|
||||
stream = client.stream.get(
|
||||
id = wrapper.stream_id, branch_limit=100
|
||||
)
|
||||
|
||||
if isinstance(stream, Exception):
|
||||
raise SpeckleException(stream.message+ ", "+ self.speckle_url)
|
||||
|
||||
for br in stream['branches']['items']:
|
||||
if br['id'] == wrapper.model_id:
|
||||
branch = br
|
||||
break
|
||||
return stream, branch
|
||||
|
||||
def get_client(wrapper: "StreamWrapper", url_proj: str) -> "SpeckleClient":
|
||||
"""Get unauthenticated SpeckleClient."""
|
||||
|
||||
from specklepy.core.api.client import SpeckleClient
|
||||
|
||||
# get client by URL, no authentication
|
||||
client = SpeckleClient(host=wrapper.host, use_ssl=wrapper.host.startswith("https"))
|
||||
client.account.serverInfo.url = url_proj.split("/projects")[0]
|
||||
return client
|
||||
|
||||
|
||||
def get_comments(client: "SpeckleClient", project_id: str, model_id: str):
|
||||
"""Query comments from the Project and Model (if recorded in Comment)."""
|
||||
|
||||
from gql import gql
|
||||
from specklepy.logging.exceptions import SpeckleException, SpeckleInvalidUnitException
|
||||
|
||||
# get Project data
|
||||
query = gql(
|
||||
"""
|
||||
query Comments ($project_id: String!) {
|
||||
project(id: $project_id) {
|
||||
commentThreads {
|
||||
totalCount
|
||||
items{
|
||||
|
||||
id
|
||||
author{
|
||||
name
|
||||
}
|
||||
createdAt
|
||||
rawText
|
||||
|
||||
text{
|
||||
attachments{
|
||||
id
|
||||
fileName
|
||||
fileType
|
||||
fileSize
|
||||
}
|
||||
}
|
||||
viewerResources{
|
||||
modelId
|
||||
}
|
||||
viewerState
|
||||
|
||||
replies{
|
||||
items{
|
||||
|
||||
id
|
||||
author{
|
||||
name
|
||||
}
|
||||
createdAt
|
||||
rawText
|
||||
|
||||
text{
|
||||
attachments{
|
||||
id
|
||||
fileName
|
||||
fileType
|
||||
fileSize
|
||||
}
|
||||
}
|
||||
viewerResources{
|
||||
modelId
|
||||
}
|
||||
viewerState
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
params = {
|
||||
"project_id": project_id,
|
||||
}
|
||||
response_data = client.httpclient.execute(query, params)
|
||||
threads = response_data["project"]["commentThreads"]["items"]
|
||||
threads_objs = {}
|
||||
for thread in threads:
|
||||
comment_data = get_info_from_comment(thread, project_id, model_id)
|
||||
if comment_data is None:
|
||||
continue
|
||||
|
||||
# unpack object
|
||||
comm_id, position, author_name, created_date, raw_text, attachments_paths, res_id = comment_data
|
||||
threads_objs[comm_id] = {
|
||||
"position": position,
|
||||
"items": [{
|
||||
"author": author_name,
|
||||
"date": created_date,
|
||||
"text": raw_text,
|
||||
"attachments": attachments_paths,
|
||||
"resource_id": res_id,
|
||||
}]
|
||||
}
|
||||
replies = thread["replies"]["items"]
|
||||
for reply in replies:
|
||||
reply_data = get_info_from_comment(reply, project_id, model_id)
|
||||
if reply_data is None:
|
||||
continue
|
||||
|
||||
# unpack reply
|
||||
_, position, author_name_reply, created_date_reply, raw_text_reply, attachments_paths_reply, _ = reply_data
|
||||
|
||||
threads_objs[comm_id]["items"].append(
|
||||
{
|
||||
"author": author_name_reply,
|
||||
"date": created_date_reply,
|
||||
"text": raw_text_reply,
|
||||
"attachments": attachments_paths_reply,
|
||||
}
|
||||
)
|
||||
|
||||
return threads_objs
|
||||
|
||||
|
||||
def get_info_from_comment(comment: Dict, project_id: str, model_id: str) -> Tuple [str, List[float], str, str, str, List[str]]:
|
||||
"""Get displayable data from commit."""
|
||||
|
||||
comm_id = comment["id"]
|
||||
author_name = comment["author"]["name"]
|
||||
created_date = comment["createdAt"]
|
||||
raw_text = comment["rawText"]
|
||||
|
||||
r'''
|
||||
resources = comment["viewerResources"]
|
||||
model_found = 1
|
||||
# assume the model is matching, only exclude if other model_id is stated
|
||||
for resource in resources:
|
||||
if resource["modelId"] == model_id:
|
||||
break
|
||||
if resource["modelId"] is not None and resource["modelId"]!="" and resource["modelId"] != model_id:
|
||||
# wrong model, don't include
|
||||
model_found = 0
|
||||
'''
|
||||
position = [0,0,0]
|
||||
res_id = model_id
|
||||
viewer_state = comment["viewerState"]
|
||||
if viewer_state is not None: # can be None for Replies
|
||||
position: List[float] = viewer_state["ui"]["selection"]
|
||||
try:
|
||||
res_id = viewer_state["resources"]["request"]["resourceIdString"]
|
||||
except:
|
||||
pass
|
||||
|
||||
attachments = comment["text"]["attachments"]
|
||||
attachments_paths = []
|
||||
for attach in attachments:
|
||||
try:
|
||||
file_path = get_attachment(project_id, attach["id"], attach["fileName"])
|
||||
attachments_paths.append(file_path)
|
||||
except:
|
||||
pass # attachment was not queried successfully
|
||||
|
||||
#if model_found is False:
|
||||
# return None
|
||||
return comm_id, position, author_name, created_date, raw_text, attachments_paths, res_id
|
||||
|
||||
def get_attachment(project_id: str, attachment_id: str, attachment_name: str) -> Path:
|
||||
|
||||
import requests
|
||||
import shutil
|
||||
|
||||
return attachment_name
|
||||
|
||||
file_path_obj: Path = Path(Path(pygeoapi.__file__).parent.parent, "Temp_attachments", attachment_name)
|
||||
print(file_path_obj)
|
||||
file_path = str(file_path_obj)
|
||||
print(file_path)
|
||||
|
||||
if os.path.isfile(file_path) is True: # if already saved
|
||||
return file_path
|
||||
|
||||
url = f"https://speckle.xyz/api/stream/{project_id}/blob/{attachment_id}"
|
||||
headers = {"User-Agent": "Speckle Pygeoapi"}
|
||||
r = requests.get(url, headers=headers, stream=True)
|
||||
|
||||
if r.status_code == 200:
|
||||
with open(file_path, "wb") as f:
|
||||
r.raw.decode_content = True
|
||||
shutil.copyfileobj(r.raw, f)
|
||||
return file_path
|
||||
else:
|
||||
raise Exception(
|
||||
f"Request not successful: Response code {r.status_code}"
|
||||
)
|
||||
|
||||
def set_actions(self: "SpeckleProvider", client: "SpeckleClient", action: str = "GEO receive"):
|
||||
from specklepy.logging.metrics import track
|
||||
try:
|
||||
full_dict = {**self.url_params, **self.times}
|
||||
full_dict["GIS commit"] = self.commit_gis
|
||||
full_dict["project_id"] = f"{self.project_id}"
|
||||
full_dict["sourceHostApp"] = self.sourceApp
|
||||
full_dict["model"] = f"{self.project_name}, {self.model_name}"
|
||||
full_dict["time_TOTAL"] = sum([x[1] for x in self.times.items()])
|
||||
full_dict["model_url"] = self.speckle_url
|
||||
full_dict["model_country_code"] = self.country_code
|
||||
track(action, client.account, full_dict)
|
||||
except Exception as ex:
|
||||
print(f"_Cannot set action '{action}': {ex}")
|
||||
pass
|
||||
@@ -1,104 +0,0 @@
|
||||
|
||||
|
||||
import inspect
|
||||
|
||||
|
||||
def get_set_url_parameters(self: "SpeckleProvider"):
|
||||
"""Parse and save URL parameters."""
|
||||
|
||||
from pygeoapi.provider.speckle_utils.crs_utils import create_crs_from_authid
|
||||
|
||||
crsauthid = False
|
||||
|
||||
if (isinstance(self.data, str)):
|
||||
|
||||
for item in self.data.lower().split("&"):
|
||||
# if CRS authid is found, rest will be ignored
|
||||
if "speckleurl=" in item:
|
||||
try:
|
||||
speckle_url = item.split("speckleurl=")[1]
|
||||
if "/projects/" not in speckle_url or "/models/" not in speckle_url:
|
||||
raise ValueError(f"Provide valid Speckle Model URL: {item}")
|
||||
|
||||
if speckle_url[-1] == "/":
|
||||
speckle_url = speckle_url[:-1]
|
||||
self.speckle_project_url = speckle_url.split("/models")[0]
|
||||
except:
|
||||
raise ValueError(f"Provide valid Speckle Model URL: {item}")
|
||||
|
||||
elif "datatype=" in item:
|
||||
try:
|
||||
requested_data_type = item.split("datatype=")[1]
|
||||
if requested_data_type in ["points", "lines", "polygons", "projectcomments"]:
|
||||
self.requested_data_type = requested_data_type
|
||||
self.url_params["url_data_type"] = requested_data_type
|
||||
except:
|
||||
raise ValueError(f"Provide valid dataType parameter (points/lines/polygons/projectcomments): {item}")
|
||||
|
||||
elif "preserveattributes=" in item:
|
||||
try:
|
||||
preserve_attributes = item.split("preserveattributes=")[1]
|
||||
if preserve_attributes in ["true", "false"]:
|
||||
self.preserve_attributes = preserve_attributes
|
||||
self.url_params["url_preserve_attributes"] = preserve_attributes
|
||||
except:
|
||||
ValueError(f"Provide valid preserverAttributes parameter (true/false): {item}")
|
||||
|
||||
elif "crsauthid=" in item:
|
||||
crs_authid = item.split("crsauthid=")[1]
|
||||
if isinstance(crs_authid, str) and len(crs_authid)>3:
|
||||
crsauthid = True
|
||||
self.crs_authid = crs_authid
|
||||
self.url_params["url_crs_authid"] = crs_authid
|
||||
|
||||
elif "lat=" in item:
|
||||
try:
|
||||
lat = float(item.split("lat=")[1])
|
||||
self.lat = lat
|
||||
self.url_params["url_lat"] = lat
|
||||
except:
|
||||
raise ValueError(f"Invalid Lat input, must be numeric: {item}")
|
||||
elif "lon=" in item:
|
||||
try:
|
||||
lon = float(item.split("lon=")[1])
|
||||
self.lon = lon
|
||||
self.url_params["url_lon"] = lon
|
||||
except:
|
||||
raise ValueError(f"Invalid Lon input, must be numeric: {item}")
|
||||
elif "northdegrees=" in item:
|
||||
try:
|
||||
north_degrees = float(item.split("northdegrees=")[1])
|
||||
self.north_degrees = north_degrees
|
||||
self.url_params["url_north_degrees"] = north_degrees
|
||||
except:
|
||||
raise ValueError(f"Invalid northDegrees input, must be numeric: {item}")
|
||||
elif "limit=" in item:
|
||||
try:
|
||||
limit = int(item.split("limit=")[1])
|
||||
if limit>0:
|
||||
self.limit = limit
|
||||
self.url_params["url_limit"] = limit
|
||||
except:
|
||||
ValueError(f"Invalid limit input, must be a positive integer: {item}")
|
||||
|
||||
elif "useragent=" in item:
|
||||
try:
|
||||
agent = item.split("useragent=")[1]
|
||||
self.user_agent = agent
|
||||
self.url_params["user_agent"] = agent
|
||||
except:
|
||||
ValueError(f"Invalid limit input, must be a positive integer: {item}")
|
||||
|
||||
|
||||
if self.speckle_url == "-":
|
||||
self.missing_url = "true"
|
||||
|
||||
# if CRS authid is found, rest will be ignored
|
||||
if crsauthid:
|
||||
self.lat = str(self.lat) + " (not applied)"
|
||||
self.lon = str(self.lon) + " (not applied)"
|
||||
self.north_degrees = 0 # default to 0: rotation ignored when AuthId is used #str(self.north_degrees) + " (not applied)"
|
||||
|
||||
# if CRS parameter present, create and assign CRS:
|
||||
if len(self.crs_authid)>3:
|
||||
create_crs_from_authid(self, self.crs_authid)
|
||||
@@ -88,7 +88,7 @@ class SQLiteGPKGProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
if not self.fields:
|
||||
if not self._fields:
|
||||
results = self.cursor.execute(
|
||||
f'PRAGMA table_info({self.table})').fetchall()
|
||||
for item in results:
|
||||
@@ -100,9 +100,9 @@ class SQLiteGPKGProvider(BaseProvider):
|
||||
json_type = 'string'
|
||||
|
||||
if json_type is not None:
|
||||
self.fields[item['name']] = {'type': json_type}
|
||||
self._fields[item['name']] = {'type': json_type}
|
||||
|
||||
return self.fields
|
||||
return self._fields
|
||||
|
||||
def __get_where_clauses(self, properties=[], bbox=[]):
|
||||
"""
|
||||
|
||||
@@ -74,7 +74,7 @@ class TinyDBProvider(BaseProvider):
|
||||
else:
|
||||
self.db = TinyDB(self.data)
|
||||
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
@@ -83,38 +83,37 @@ class TinyDBProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
fields = {}
|
||||
if not self._fields:
|
||||
try:
|
||||
r = self.db.all()[0]
|
||||
except IndexError as err:
|
||||
LOGGER.debug(err)
|
||||
return {}
|
||||
|
||||
try:
|
||||
r = self.db.all()[0]
|
||||
except IndexError as err:
|
||||
LOGGER.debug(err)
|
||||
return fields
|
||||
|
||||
for key, value in r['properties'].items():
|
||||
if key not in self._excludes:
|
||||
typed_value = get_typed_value(str(value))
|
||||
if isinstance(typed_value, float):
|
||||
typed_value_type = 'number'
|
||||
elif isinstance(typed_value, int):
|
||||
typed_value_type = 'integer'
|
||||
else:
|
||||
typed_value_type = 'string'
|
||||
|
||||
fields[key] = {'type': typed_value_type}
|
||||
|
||||
try:
|
||||
LOGGER.debug('Attempting to detect date types')
|
||||
_ = parse_date(value)
|
||||
if len(value) > 11:
|
||||
fields[key]['format'] = 'date-time'
|
||||
for key, value in r['properties'].items():
|
||||
if key not in self._excludes:
|
||||
typed_value = get_typed_value(str(value))
|
||||
if isinstance(typed_value, float):
|
||||
typed_value_type = 'number'
|
||||
elif isinstance(typed_value, int):
|
||||
typed_value_type = 'integer'
|
||||
else:
|
||||
fields[key]['format'] = 'date'
|
||||
except Exception:
|
||||
LOGGER.debug('No date types detected')
|
||||
pass
|
||||
typed_value_type = 'string'
|
||||
|
||||
return fields
|
||||
self._fields[key] = {'type': typed_value_type}
|
||||
|
||||
try:
|
||||
LOGGER.debug('Attempting to detect date types')
|
||||
_ = parse_date(value)
|
||||
if len(value) > 11:
|
||||
self._fields[key]['format'] = 'date-time'
|
||||
else:
|
||||
self._fields[key]['format'] = 'date'
|
||||
except Exception:
|
||||
LOGGER.debug('No date types detected')
|
||||
pass
|
||||
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
@@ -349,7 +348,10 @@ class TinyDBCatalogueProvider(TinyDBProvider):
|
||||
def __init__(self, provider_def):
|
||||
super().__init__(provider_def)
|
||||
|
||||
LOGGER.debug('Refreshing fields')
|
||||
self._excludes = ['_metadata-anytext']
|
||||
self._fields = {}
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
fields = super().get_fields()
|
||||
|
||||
@@ -84,7 +84,9 @@ class WMSFacadeProvider(BaseProvider):
|
||||
|
||||
self._transparent = 'TRUE'
|
||||
|
||||
if crs in [4326, 'CRS;84']:
|
||||
version = self.options.get('version', '1.3.0')
|
||||
|
||||
if crs in [4326, 'CRS;84'] and version == '1.3.0':
|
||||
LOGGER.debug('Swapping 4326 axis order to WMS 1.3 mode (yx)')
|
||||
bbox2 = ','.join(str(c) for c in
|
||||
[bbox[1], bbox[0], bbox[3], bbox[2]])
|
||||
@@ -106,12 +108,14 @@ class WMSFacadeProvider(BaseProvider):
|
||||
if not transparent:
|
||||
self._transparent = 'FALSE'
|
||||
|
||||
crs_param = 'crs' if version == '1.3.0' else 'srs'
|
||||
|
||||
params = {
|
||||
'version': '1.3.0',
|
||||
'version': version,
|
||||
'service': 'WMS',
|
||||
'request': 'GetMap',
|
||||
'bbox': bbox2,
|
||||
'crs': CRS_CODES[crs],
|
||||
crs_param: CRS_CODES[crs],
|
||||
'layers': self.options['layer'],
|
||||
'styles': self.options.get('style', 'default'),
|
||||
'width': width,
|
||||
@@ -128,7 +132,7 @@ class WMSFacadeProvider(BaseProvider):
|
||||
else:
|
||||
request_url = '?'.join([self.data, urlencode(params)])
|
||||
|
||||
LOGGER.debug(f'WMS 1.3.0 request url: {request_url}')
|
||||
LOGGER.debug(f'WMS {version} request url: {request_url}')
|
||||
|
||||
response = requests.get(request_url)
|
||||
|
||||
|
||||
+210
-80
@@ -37,12 +37,16 @@ import zipfile
|
||||
import xarray
|
||||
import fsspec
|
||||
import numpy as np
|
||||
import pyproj
|
||||
from pyproj.exceptions import CRSError
|
||||
|
||||
from pygeoapi.api import DEFAULT_STORAGE_CRS
|
||||
|
||||
from pygeoapi.provider.base import (BaseProvider,
|
||||
ProviderConnectionError,
|
||||
ProviderNoDataError,
|
||||
ProviderQueryError)
|
||||
from pygeoapi.util import read_data
|
||||
from pygeoapi.util import get_crs_from_uri, read_data
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,35 +85,43 @@ class XarrayProvider(BaseProvider):
|
||||
else:
|
||||
data_to_open = self.data
|
||||
|
||||
self._data = open_func(data_to_open)
|
||||
try:
|
||||
self._data = open_func(data_to_open)
|
||||
except ValueError as err:
|
||||
# Manage non-cf-compliant time dimensions
|
||||
if 'time' in str(err):
|
||||
self._data = open_func(self.data, decode_times=False)
|
||||
else:
|
||||
raise err
|
||||
|
||||
self.storage_crs = self._parse_storage_crs(provider_def)
|
||||
self._coverage_properties = self._get_coverage_properties()
|
||||
|
||||
self.axes = [self._coverage_properties['x_axis_label'],
|
||||
self._coverage_properties['y_axis_label'],
|
||||
self._coverage_properties['time_axis_label']]
|
||||
self.axes = self._coverage_properties['axes']
|
||||
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
except Exception as err:
|
||||
LOGGER.warning(err)
|
||||
raise ProviderConnectionError(err)
|
||||
|
||||
def get_fields(self):
|
||||
fields = {}
|
||||
if not self._fields:
|
||||
for key, value in self._data.variables.items():
|
||||
if key not in self._data.coords:
|
||||
LOGGER.debug('Adding variable')
|
||||
dtype = value.dtype
|
||||
if dtype.name.startswith('float'):
|
||||
dtype = 'number'
|
||||
elif dtype.name.startswith('int'):
|
||||
dtype = 'integer'
|
||||
|
||||
for key, value in self._data.variables.items():
|
||||
if len(value.shape) >= 3:
|
||||
LOGGER.debug('Adding variable')
|
||||
dtype = value.dtype
|
||||
if dtype.name.startswith('float'):
|
||||
dtype = 'number'
|
||||
self._fields[key] = {
|
||||
'type': dtype,
|
||||
'title': value.attrs.get('long_name'),
|
||||
'x-ogc-unit': value.attrs.get('units')
|
||||
}
|
||||
|
||||
fields[key] = {
|
||||
'type': dtype,
|
||||
'title': value.attrs['long_name'],
|
||||
'x-ogc-unit': value.attrs.get('units')
|
||||
}
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def query(self, properties=[], subsets={}, bbox=[], bbox_crs=4326,
|
||||
datetime_=None, format_='json', **kwargs):
|
||||
@@ -138,9 +150,9 @@ class XarrayProvider(BaseProvider):
|
||||
|
||||
data = self._data[[*properties]]
|
||||
|
||||
if any([self._coverage_properties['x_axis_label'] in subsets,
|
||||
self._coverage_properties['y_axis_label'] in subsets,
|
||||
self._coverage_properties['time_axis_label'] in subsets,
|
||||
if any([self._coverage_properties.get('x_axis_label') in subsets,
|
||||
self._coverage_properties.get('y_axis_label') in subsets,
|
||||
self._coverage_properties.get('time_axis_label') in subsets,
|
||||
datetime_ is not None]):
|
||||
|
||||
LOGGER.debug('Creating spatio-temporal subset')
|
||||
@@ -159,18 +171,36 @@ class XarrayProvider(BaseProvider):
|
||||
self._coverage_properties['y_axis_label'] in subsets,
|
||||
len(bbox) > 0]):
|
||||
msg = 'bbox and subsetting by coordinates are exclusive'
|
||||
LOGGER.warning(msg)
|
||||
LOGGER.error(msg)
|
||||
raise ProviderQueryError(msg)
|
||||
else:
|
||||
query_params[self._coverage_properties['x_axis_label']] = \
|
||||
slice(bbox[0], bbox[2])
|
||||
query_params[self._coverage_properties['y_axis_label']] = \
|
||||
slice(bbox[1], bbox[3])
|
||||
x_axis_label = self._coverage_properties['x_axis_label']
|
||||
x_coords = data.coords[x_axis_label]
|
||||
if x_coords.values[0] > x_coords.values[-1]:
|
||||
LOGGER.debug(
|
||||
'Reversing slicing of x axis from high to low'
|
||||
)
|
||||
query_params[x_axis_label] = slice(bbox[2], bbox[0])
|
||||
else:
|
||||
query_params[x_axis_label] = slice(bbox[0], bbox[2])
|
||||
y_axis_label = self._coverage_properties['y_axis_label']
|
||||
y_coords = data.coords[y_axis_label]
|
||||
if y_coords.values[0] > y_coords.values[-1]:
|
||||
LOGGER.debug(
|
||||
'Reversing slicing of y axis from high to low'
|
||||
)
|
||||
query_params[y_axis_label] = slice(bbox[3], bbox[1])
|
||||
else:
|
||||
query_params[y_axis_label] = slice(bbox[1], bbox[3])
|
||||
|
||||
LOGGER.debug('bbox_crs is not currently handled')
|
||||
|
||||
if datetime_ is not None:
|
||||
if self._coverage_properties['time_axis_label'] in subsets:
|
||||
if self._coverage_properties['time_axis_label'] is None:
|
||||
msg = 'Dataset does not contain a time axis'
|
||||
LOGGER.error(msg)
|
||||
raise ProviderQueryError(msg)
|
||||
elif self._coverage_properties['time_axis_label'] in subsets:
|
||||
msg = 'datetime and temporal subsetting are exclusive'
|
||||
LOGGER.error(msg)
|
||||
raise ProviderQueryError(msg)
|
||||
@@ -192,13 +222,15 @@ class XarrayProvider(BaseProvider):
|
||||
LOGGER.warning(err)
|
||||
raise ProviderQueryError(err)
|
||||
|
||||
if (any([data.coords[self.x_field].size == 0,
|
||||
data.coords[self.y_field].size == 0,
|
||||
data.coords[self.time_field].size == 0])):
|
||||
if any(size == 0 for size in data.sizes.values()):
|
||||
msg = 'No data found'
|
||||
LOGGER.warning(msg)
|
||||
raise ProviderNoDataError(msg)
|
||||
|
||||
if format_ == 'json':
|
||||
# json does not support float32
|
||||
data = _convert_float32_to_float64(data)
|
||||
|
||||
out_meta = {
|
||||
'bbox': [
|
||||
data.coords[self.x_field].values[0],
|
||||
@@ -206,18 +238,20 @@ class XarrayProvider(BaseProvider):
|
||||
data.coords[self.x_field].values[-1],
|
||||
data.coords[self.y_field].values[-1]
|
||||
],
|
||||
"time": [
|
||||
_to_datetime_string(data.coords[self.time_field].values[0]),
|
||||
_to_datetime_string(data.coords[self.time_field].values[-1])
|
||||
],
|
||||
"driver": "xarray",
|
||||
"height": data.sizes[self.y_field],
|
||||
"width": data.sizes[self.x_field],
|
||||
"time_steps": data.sizes[self.time_field],
|
||||
"variables": {var_name: var.attrs
|
||||
for var_name, var in data.variables.items()}
|
||||
}
|
||||
|
||||
if self.time_field is not None:
|
||||
out_meta['time'] = [
|
||||
_to_datetime_string(data.coords[self.time_field].values[0]),
|
||||
_to_datetime_string(data.coords[self.time_field].values[-1]),
|
||||
]
|
||||
out_meta["time_steps"] = data.sizes[self.time_field]
|
||||
|
||||
LOGGER.debug('Serializing data in memory')
|
||||
if format_ == 'json':
|
||||
LOGGER.debug('Creating output in CoverageJSON')
|
||||
@@ -226,9 +260,11 @@ class XarrayProvider(BaseProvider):
|
||||
LOGGER.debug('Returning data in native zarr format')
|
||||
return _get_zarr_data(data)
|
||||
else: # return data in native format
|
||||
with tempfile.TemporaryFile() as fp:
|
||||
with tempfile.NamedTemporaryFile() as fp:
|
||||
LOGGER.debug('Returning data in native NetCDF format')
|
||||
fp.write(data.to_netcdf())
|
||||
data.to_netcdf(
|
||||
fp.name
|
||||
) # we need to pass a string to be able to use the "netcdf4" engine # noqa
|
||||
fp.seek(0)
|
||||
return fp.read()
|
||||
|
||||
@@ -238,14 +274,18 @@ class XarrayProvider(BaseProvider):
|
||||
|
||||
:param metadata: coverage metadata
|
||||
:param data: rasterio DatasetReader object
|
||||
:param fields: fields dict
|
||||
:param fields: fields
|
||||
|
||||
:returns: dict of CoverageJSON representation
|
||||
"""
|
||||
|
||||
LOGGER.debug('Creating CoverageJSON domain')
|
||||
minx, miny, maxx, maxy = metadata['bbox']
|
||||
mint, maxt = metadata['time']
|
||||
|
||||
selected_fields = {
|
||||
key: value for key, value in self.fields.items()
|
||||
if key in fields
|
||||
}
|
||||
|
||||
try:
|
||||
tmp_min = data.coords[self.y_field].values[0]
|
||||
@@ -276,11 +316,6 @@ class XarrayProvider(BaseProvider):
|
||||
'start': maxy,
|
||||
'stop': miny,
|
||||
'num': metadata['height']
|
||||
},
|
||||
self.time_field: {
|
||||
'start': mint,
|
||||
'stop': maxt,
|
||||
'num': metadata['time_steps']
|
||||
}
|
||||
},
|
||||
'referencing': [{
|
||||
@@ -295,7 +330,15 @@ class XarrayProvider(BaseProvider):
|
||||
'ranges': {}
|
||||
}
|
||||
|
||||
for key, value in self.fields.items():
|
||||
if self.time_field is not None:
|
||||
mint, maxt = metadata['time']
|
||||
cj['domain']['axes'][self.time_field] = {
|
||||
'start': mint,
|
||||
'stop': maxt,
|
||||
'num': metadata['time_steps'],
|
||||
}
|
||||
|
||||
for key, value in selected_fields.items():
|
||||
parameter = {
|
||||
'type': 'Parameter',
|
||||
'description': value['title'],
|
||||
@@ -313,21 +356,25 @@ class XarrayProvider(BaseProvider):
|
||||
cj['parameters'][key] = parameter
|
||||
|
||||
data = data.fillna(None)
|
||||
data = _convert_float32_to_float64(data)
|
||||
|
||||
try:
|
||||
for key, value in self.fields.items():
|
||||
for key, value in selected_fields.items():
|
||||
cj['ranges'][key] = {
|
||||
'type': 'NdArray',
|
||||
'dataType': value['type'],
|
||||
'axisNames': [
|
||||
'y', 'x', self._coverage_properties['time_axis_label']
|
||||
'y', 'x'
|
||||
],
|
||||
'shape': [metadata['height'],
|
||||
metadata['width'],
|
||||
metadata['time_steps']]
|
||||
metadata['width']]
|
||||
}
|
||||
cj['ranges'][key]['values'] = data[key].values.flatten().tolist() # noqa
|
||||
|
||||
if self.time_field is not None:
|
||||
cj['ranges'][key]['axisNames'].append(
|
||||
self._coverage_properties['time_axis_label']
|
||||
)
|
||||
cj['ranges'][key]['shape'].append(metadata['time_steps'])
|
||||
except IndexError as err:
|
||||
LOGGER.warning(err)
|
||||
raise ProviderQueryError('Invalid query parameter')
|
||||
@@ -337,6 +384,7 @@ class XarrayProvider(BaseProvider):
|
||||
def _get_coverage_properties(self):
|
||||
"""
|
||||
Helper function to normalize coverage properties
|
||||
:param provider_def: provider definition
|
||||
|
||||
:returns: `dict` of coverage properties
|
||||
"""
|
||||
@@ -372,48 +420,61 @@ class XarrayProvider(BaseProvider):
|
||||
self._data.coords[self.x_field].values[-1],
|
||||
self._data.coords[self.y_field].values[-1],
|
||||
],
|
||||
'time_range': [
|
||||
_to_datetime_string(
|
||||
self._data.coords[self.time_field].values[0]
|
||||
),
|
||||
_to_datetime_string(
|
||||
self._data.coords[self.time_field].values[-1]
|
||||
)
|
||||
],
|
||||
'bbox_crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||
'crs_type': 'GeographicCRS',
|
||||
'x_axis_label': self.x_field,
|
||||
'y_axis_label': self.y_field,
|
||||
'time_axis_label': self.time_field,
|
||||
'width': self._data.sizes[self.x_field],
|
||||
'height': self._data.sizes[self.y_field],
|
||||
'time': self._data.sizes[self.time_field],
|
||||
'time_duration': self.get_time_coverage_duration(),
|
||||
'bbox_units': 'degrees',
|
||||
'resx': np.abs(self._data.coords[self.x_field].values[1]
|
||||
- self._data.coords[self.x_field].values[0]),
|
||||
'resy': np.abs(self._data.coords[self.y_field].values[1]
|
||||
- self._data.coords[self.y_field].values[0]),
|
||||
'restime': self.get_time_resolution()
|
||||
'resx': np.abs(
|
||||
self._data.coords[self.x_field].values[1]
|
||||
- self._data.coords[self.x_field].values[0]
|
||||
),
|
||||
'resy': np.abs(
|
||||
self._data.coords[self.y_field].values[1]
|
||||
- self._data.coords[self.y_field].values[0]
|
||||
),
|
||||
}
|
||||
|
||||
if 'crs' in self._data.variables.keys():
|
||||
try:
|
||||
properties['bbox_crs'] = f'http://www.opengis.net/def/crs/OGC/1.3/{self._data.crs.epsg_code}' # noqa
|
||||
|
||||
properties['inverse_flattening'] = self._data.crs.\
|
||||
inverse_flattening
|
||||
if self.time_field is not None:
|
||||
properties['time_axis_label'] = self.time_field
|
||||
properties['time_range'] = [
|
||||
_to_datetime_string(
|
||||
self._data.coords[self.time_field].values[0]
|
||||
),
|
||||
_to_datetime_string(
|
||||
self._data.coords[self.time_field].values[-1]
|
||||
),
|
||||
]
|
||||
properties['time'] = self._data.sizes[self.time_field]
|
||||
properties['time_duration'] = self.get_time_coverage_duration()
|
||||
properties['restime'] = self.get_time_resolution()
|
||||
|
||||
# Update properties based on the xarray's CRS
|
||||
epsg_code = self.storage_crs.to_epsg()
|
||||
LOGGER.debug(f'{epsg_code}')
|
||||
if epsg_code == 4326 or self.storage_crs == 'OGC:CRS84':
|
||||
pass
|
||||
LOGGER.debug('Confirmed default of WGS 84')
|
||||
else:
|
||||
properties['bbox_crs'] = \
|
||||
f'https://www.opengis.net/def/crs/EPSG/0/{epsg_code}'
|
||||
properties['inverse_flattening'] = \
|
||||
self.storage_crs.ellipsoid.inverse_flattening
|
||||
if self.storage_crs.is_projected:
|
||||
properties['crs_type'] = 'ProjectedCRS'
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
LOGGER.debug(f'properties: {properties}')
|
||||
|
||||
properties['axes'] = [
|
||||
properties['x_axis_label'],
|
||||
properties['y_axis_label'],
|
||||
properties['time_axis_label']
|
||||
properties['y_axis_label']
|
||||
]
|
||||
|
||||
if self.time_field is not None:
|
||||
properties['axes'].append(properties['time_axis_label'])
|
||||
|
||||
return properties
|
||||
|
||||
@staticmethod
|
||||
@@ -440,7 +501,8 @@ class XarrayProvider(BaseProvider):
|
||||
:returns: time resolution string
|
||||
"""
|
||||
|
||||
if self._data[self.time_field].size > 1:
|
||||
if self.time_field is not None \
|
||||
and self._data[self.time_field].size > 1:
|
||||
time_diff = (self._data[self.time_field][1] -
|
||||
self._data[self.time_field][0])
|
||||
|
||||
@@ -457,6 +519,9 @@ class XarrayProvider(BaseProvider):
|
||||
:returns: time coverage duration string
|
||||
"""
|
||||
|
||||
if self.time_field is None:
|
||||
return None
|
||||
|
||||
dur = self._data[self.time_field][-1] - self._data[self.time_field][0]
|
||||
ms_difference = dur.values.astype('timedelta64[ms]').astype(np.double)
|
||||
|
||||
@@ -472,6 +537,71 @@ class XarrayProvider(BaseProvider):
|
||||
|
||||
return ', '.join(times)
|
||||
|
||||
def _parse_grid_mapping(self):
|
||||
"""
|
||||
Identifies grid_mapping.
|
||||
|
||||
:returns: name of xarray data variable that contains CRS information.
|
||||
"""
|
||||
LOGGER.debug('Parsing grid mapping...')
|
||||
spatiotemporal_dims = (self.time_field, self.y_field, self.x_field)
|
||||
LOGGER.debug(spatiotemporal_dims)
|
||||
grid_mapping_name = None
|
||||
for var_name, var in self._data.variables.items():
|
||||
if all(dim in var.dims for dim in spatiotemporal_dims):
|
||||
try:
|
||||
grid_mapping_name = self._data[var_name].attrs['grid_mapping'] # noqa
|
||||
LOGGER.debug(f'Grid mapping: {grid_mapping_name}')
|
||||
except KeyError as err:
|
||||
LOGGER.debug(err)
|
||||
LOGGER.debug('No grid mapping information found.')
|
||||
return grid_mapping_name
|
||||
|
||||
def _parse_storage_crs(
|
||||
self,
|
||||
provider_def: dict
|
||||
) -> pyproj.CRS:
|
||||
"""
|
||||
Parse the storage CRS from an xarray dataset.
|
||||
|
||||
:param provider_def: provider definition
|
||||
|
||||
:returns: `pyproj.CRS` instance parsed from dataset
|
||||
"""
|
||||
storage_crs = None
|
||||
|
||||
try:
|
||||
storage_crs = provider_def['storage_crs']
|
||||
crs_function = pyproj.CRS.from_user_input
|
||||
except KeyError as err:
|
||||
LOGGER.debug(err)
|
||||
LOGGER.debug('No storage_crs found. Attempting to parse the CRS.')
|
||||
|
||||
if storage_crs is None:
|
||||
grid_mapping = self._parse_grid_mapping()
|
||||
if grid_mapping is not None:
|
||||
storage_crs = self._data[grid_mapping].attrs
|
||||
crs_function = pyproj.CRS.from_cf
|
||||
elif 'crs' in self._data.variables.keys():
|
||||
storage_crs = self._data['crs'].attrs
|
||||
crs_function = pyproj.CRS.from_dict
|
||||
else:
|
||||
storage_crs = DEFAULT_STORAGE_CRS
|
||||
crs_function = get_crs_from_uri
|
||||
LOGGER.debug('Failed to parse dataset CRS. Assuming WGS84.')
|
||||
|
||||
LOGGER.debug(f'Parsing CRS {storage_crs} with {crs_function}')
|
||||
try:
|
||||
crs = crs_function(storage_crs)
|
||||
except CRSError as err:
|
||||
LOGGER.debug(f'Unable to parse projection with pyproj: {err}')
|
||||
LOGGER.debug('Assuming default WGS84.')
|
||||
crs = get_crs_from_uri(DEFAULT_STORAGE_CRS)
|
||||
|
||||
LOGGER.debug(crs)
|
||||
|
||||
return crs
|
||||
|
||||
|
||||
def _to_datetime_string(datetime_obj):
|
||||
"""
|
||||
@@ -554,7 +684,7 @@ def _convert_float32_to_float64(data):
|
||||
for var_name in data.variables:
|
||||
if data[var_name].dtype == 'float32':
|
||||
og_attrs = data[var_name].attrs
|
||||
data[var_name] = data[var_name].astype('float64')
|
||||
data[var_name] = data[var_name].astype('float64', copy=False)
|
||||
data[var_name].attrs = og_attrs
|
||||
|
||||
return data
|
||||
|
||||
@@ -81,14 +81,14 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
|
||||
wkt = kwargs.get('wkt')
|
||||
if wkt is not None:
|
||||
LOGGER.debug('Processing WKT')
|
||||
LOGGER.debug(f'Geometry type: {wkt.type}')
|
||||
if wkt.type == 'Point':
|
||||
LOGGER.debug(f'Geometry type: {wkt.geom_type}')
|
||||
if wkt.geom_type == 'Point':
|
||||
query_params[self._coverage_properties['x_axis_label']] = wkt.x
|
||||
query_params[self._coverage_properties['y_axis_label']] = wkt.y
|
||||
elif wkt.type == 'LineString':
|
||||
elif wkt.geom_type == 'LineString':
|
||||
query_params[self._coverage_properties['x_axis_label']] = wkt.xy[0] # noqa
|
||||
query_params[self._coverage_properties['y_axis_label']] = wkt.xy[1] # noqa
|
||||
elif wkt.type == 'Polygon':
|
||||
elif wkt.geom_type == 'Polygon':
|
||||
query_params[self._coverage_properties['x_axis_label']] = slice(wkt.bounds[0], wkt.bounds[2]) # noqa
|
||||
query_params[self._coverage_properties['y_axis_label']] = slice(wkt.bounds[1], wkt.bounds[3]) # noqa
|
||||
pass
|
||||
@@ -109,7 +109,7 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
|
||||
|
||||
try:
|
||||
if select_properties:
|
||||
self.fields = {k: v for k, v in self.fields.items() if k in select_properties} # noqa
|
||||
self._fields = {k: v for k, v in self._fields.items() if k in select_properties} # noqa
|
||||
data = self._data[[*select_properties]]
|
||||
else:
|
||||
data = self._data
|
||||
@@ -206,7 +206,7 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
|
||||
LOGGER.debug(f'query parameters: {query_params}')
|
||||
try:
|
||||
if select_properties:
|
||||
self.fields = {k: v for k, v in self.fields.items() if k in select_properties} # noqa
|
||||
self._fields = {k: v for k, v in self._fields.items() if k in select_properties} # noqa
|
||||
data = self._data[[*select_properties]]
|
||||
else:
|
||||
data = self._data
|
||||
|
||||
@@ -334,11 +334,7 @@ async def collection_items(request: Request, collection_id=None, item_id=None):
|
||||
if 'item_id' in request.path_params:
|
||||
item_id = request.path_params['item_id']
|
||||
if item_id is None:
|
||||
if request.method == 'GET': # list items
|
||||
return await execute_from_starlette(
|
||||
itemtypes_api.get_collection_items, request, collection_id,
|
||||
skip_valid_check=True)
|
||||
elif request.method == 'POST': # filter or manage items
|
||||
if request.method == 'POST': # filter or manage items
|
||||
content_type = request.headers.get('content-type')
|
||||
if content_type is not None:
|
||||
if content_type == 'application/geo+json':
|
||||
@@ -357,6 +353,10 @@ async def collection_items(request: Request, collection_id=None, item_id=None):
|
||||
itemtypes_api.manage_collection_item, request,
|
||||
'options', collection_id, skip_valid_check=True,
|
||||
)
|
||||
else: # GET: list items
|
||||
return await execute_from_starlette(
|
||||
itemtypes_api.get_collection_items, request, collection_id,
|
||||
skip_valid_check=True)
|
||||
|
||||
elif request.method == 'DELETE':
|
||||
return await execute_from_starlette(
|
||||
@@ -511,12 +511,13 @@ async def get_job_result_resource(request: Request,
|
||||
api_.get_job_result_resource, request, job_id, resource)
|
||||
|
||||
|
||||
async def get_collection_edr_query(request: Request, collection_id=None, instance_id=None): # noqa
|
||||
async def get_collection_edr_query(request: Request, collection_id=None, instance_id=None, location_id=None): # noqa
|
||||
"""
|
||||
OGC EDR API endpoints
|
||||
|
||||
:param collection_id: collection identifier
|
||||
:param instance_id: instance identifier
|
||||
:param location_id: location id of a /locations/<location_id> query
|
||||
|
||||
:returns: HTTP response
|
||||
"""
|
||||
@@ -527,10 +528,15 @@ async def get_collection_edr_query(request: Request, collection_id=None, instanc
|
||||
if 'instance_id' in request.path_params:
|
||||
instance_id = request.path_params['instance_id']
|
||||
|
||||
query_type = request["path"].split('/')[-1] # noqa
|
||||
if 'location_id' in request.path_params:
|
||||
location_id = request.path_params['location_id']
|
||||
query_type = 'locations'
|
||||
else:
|
||||
query_type = request['path'].split('/')[-1]
|
||||
|
||||
return await execute_from_starlette(
|
||||
edr_api.get_collection_edr_query, request, collection_id,
|
||||
instance_id, query_type,
|
||||
instance_id, query_type, location_id,
|
||||
skip_valid_check=True,
|
||||
)
|
||||
|
||||
@@ -746,6 +752,7 @@ if CONFIG['server'].get('cors', False):
|
||||
CORSMiddleware,
|
||||
allow_origins=['*'],
|
||||
allow_methods=['*'],
|
||||
expose_headers=['*']
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -12,7 +12,7 @@ main {
|
||||
|
||||
.crumbs {
|
||||
background-color:rgb(230, 230, 230);
|
||||
padding: 0px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.crumbs a {
|
||||
@@ -27,6 +27,15 @@ main {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
#coverages-map {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
.c3-tooltip-container {
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
/* cancel mini-css header>button uppercase */
|
||||
header button, header [type="button"], header .button, header [role="button"] {
|
||||
text-transform: none;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
+64
-120
@@ -1,69 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<style>
|
||||
|
||||
.switch {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
right: 1vw;
|
||||
top: 10px;
|
||||
width: 70px;
|
||||
height: 23px;
|
||||
z-index: 1000000;
|
||||
}
|
||||
|
||||
.switch span {
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
}
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #2196F3;
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px #2196F3;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
-webkit-transform: translateX(17px);
|
||||
-ms-transform: translateX(17px);
|
||||
transform: translateX(17px);
|
||||
}
|
||||
|
||||
</style>
|
||||
<html lang="{{ (locale|lower)[:2] }}" dir="{% trans %}text_direction{% endtrans %}" >
|
||||
<head>
|
||||
<meta charset="{{ config['server']['encoding'] }}">
|
||||
<title>{% block title %}{% endblock %}{% if not self.title() %}{{ config['metadata']['identification']['title'] }}{% endif %}</title>
|
||||
@@ -71,7 +7,7 @@
|
||||
<meta name="language" content="{{ config['server']['language'] }}">
|
||||
<meta name="description" content="{{ config['metadata']['identification']['title'] }}">
|
||||
<meta name="keywords" content="{{ config['metadata']['identification']['keywords']|join(',') }}">
|
||||
<link rel="shortcut icon" href="https://github.com/specklesystems/pygeoapi/blob/dev/pygeoapi/static/img/speckle_geo.png" type="image/x-icon">
|
||||
<link rel="shortcut icon" href="{{ config['server']['url'] }}/static/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="https://unpkg.com/bootstrap@5.1.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="{{ config['server']['url'] }}/static/css/default.css">
|
||||
<!--[if lt IE 9]>
|
||||
@@ -99,72 +35,80 @@
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-white sticky-top border-bottom" >
|
||||
<div class="container" style="max-height:fit-content;max-width: fit-content;margin-left: 10px;">
|
||||
<header class="d-flex flex-wrap justify-content-center py-2" style="text-align: left;">
|
||||
<a href="{{ config['metadata']['contact']['url'] }}" target="_blank"
|
||||
class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none"
|
||||
style="text-align:left;">
|
||||
<img src="{{ config['server']['url'] }}/static/img/speckle_cube_32.png" alt="Speckle"
|
||||
title="{{ config['metadata']['identification']['title'] }}" style="height:30px;vertical-align: middle;" />
|
||||
<b style="text-align:left;padding-left: 10px;">Speckle</b>
|
||||
</a>
|
||||
<a href="https://geo.speckle.systems/" target="_blank" style="text-align:left;padding-left: 10px;" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
|
||||
> Geolocating your data
|
||||
</a>
|
||||
{% if (data["model"] and data["model"]!="") %}
|
||||
<a href="{{data['speckle_project_url']}}" target="_blank" style="text-align:left;padding-left: 10px;" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
|
||||
> {{data["project"]}} >
|
||||
</a>
|
||||
<a href="{{data['speckle_url']}}" target="_blank" style="text-align:left;padding-left: 10px;color:rgb(10,132,255);" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-decoration-none">
|
||||
{{data["model"]}} {{data["limit_message"]}}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group" >
|
||||
<label class="switch">3D
|
||||
<input id="modeSwitch" type="checkbox">
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-pills"> </ul>
|
||||
<div class="bg-light sticky-top border-bottom">
|
||||
<div class="container">
|
||||
<header class="d-flex flex-wrap align-items-center py-3 justify-content-between">
|
||||
<a href="{{ config['server']['url'] }}"
|
||||
class="d-flex align-items-center mb-3 mb-md-0 text-dark text-decoration-none">
|
||||
<img src="{{ config['server']['url'] }}/static/img/logo.png"
|
||||
title="{{ config['metadata']['identification']['title'] }}" style="height:40px;vertical-align: middle;" /></a>
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item">
|
||||
<a href="mailto:{{ config['metadata']['contact']['email'] }}" class="nav-link" aria-current="page">{% trans %}Contact{% endtrans%}</a>
|
||||
</li>
|
||||
{% if config['server']['admin'] %}
|
||||
<li class="nav-item">
|
||||
<a href="{{ config['server']['url'] }}/admin/config" class="nav-link" aria-current="page">{% trans %}Admin{% endtrans %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<!--
|
||||
Add additional menu items here
|
||||
<a href="https://pygeoapi.io" class="nav-link">About</a>
|
||||
-->
|
||||
</ul>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div style="max-height:fit-content;margin:0px;padding:0px;background-color: rgb(10,132,255);">
|
||||
<p style="text-align: center; margin:0px;padding:5px;">
|
||||
<a href = "https://docs.google.com/forms/d/e/1FAIpQLScKW2pkcWll3deXEwoV_G5ozLtuU06_prw8rf8HFuCk4tmOPQ/viewform?usp=sf_link"
|
||||
style="color:rgb(255, 255, 255)" target="_blank">We would love to hear your feedback!</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="crumbs">
|
||||
|
||||
</div>
|
||||
|
||||
<main style="background-color:WhiteSmoke;">
|
||||
<div style="margin-left: 20px;margin-right: 20px;">
|
||||
{% block body_map %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
{% block crumbs %}
|
||||
<a href="{{ config['server']['url'] }}">{% trans %}Home{% endtrans %}</a>
|
||||
{% endblock %}
|
||||
<span style="float: inline-end">
|
||||
{% set links_found = namespace(json=0, jsonld=0) %}
|
||||
|
||||
{% for link in data['links'] %}
|
||||
{% if link['rel'] == 'alternate' and link['type'] and link['type'] in ['application/json', 'application/geo+json', 'application/prs.coverage+json'] %}
|
||||
{% set links_found.json = 1 %}
|
||||
<a href="{{ link['href'] }}">{% trans %}json{% endtrans %}</a>
|
||||
{% elif link['rel'] == 'alternate' and link['type'] and link['type'] == 'application/ld+json' %}
|
||||
{% set links_found.jsonld = 1 %}
|
||||
<a href="{{ link['href'] }}">{% trans %}jsonld{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if links_found.json == 0 %}
|
||||
<a href="?f=json">{% trans %}json{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% if links_found.jsonld == 0 %}
|
||||
<a href="?f=jsonld">{% trans %}jsonld{% endtrans %}</a>
|
||||
{% endif %}
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<main>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<br/>
|
||||
{% block body %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="sticky-bottom bg-white d-flex flex-wrap py-3 px-3 border-top">{% trans %}Powered by {% endtrans %} <a title="pygeoapi" href="https://pygeoapi.io"><img
|
||||
src="{{ config['server']['url'] }}/static/img/pygeoapi.png" class="mx-1" title="pygeoapi logo"
|
||||
style="height:24px;vertical-align: middle;" /></a> {{ version }}
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
<footer class="sticky-bottom bg-light d-flex justify-content-center align-items-center py-3 px-3 border-top">
|
||||
<div class="text-center w-100">
|
||||
{% trans %}Powered by {% endtrans %}
|
||||
<a title="pygeoapi" href="https://pygeoapi.io"><img src="{{ config['server']['url'] }}/static/img/pygeoapi.png" class="mx-1" title="pygeoapi logo" style="height:24px;vertical-align: middle;" /></a>
|
||||
{{ version }}
|
||||
</div>
|
||||
</footer>
|
||||
{% block extrafoot %}
|
||||
{% endblock %}
|
||||
<script>
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
]);
|
||||
|
||||
map.addLayer(bbox_layer);
|
||||
map.fitBounds(bbox_layer.getBounds(), {maxZoom: 22});
|
||||
map.fitBounds(bbox_layer.getBounds(), {maxZoom: 10});
|
||||
|
||||
// Allow to get bbox query parameter of a rectangular area specified by
|
||||
// dragging the mouse while pressing the Ctrl key
|
||||
|
||||
@@ -8,56 +8,281 @@
|
||||
{% set col_title = link['title'] %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
/ <a href="{{ data['items_path']}}">{% trans %}Items{% endtrans %}</a>
|
||||
/ <a href="{{ data['query_path']}}">{% trans query_type=data.query_type %}{{ query_type }}{% endtrans %}</a>
|
||||
{% endblock %}
|
||||
{% block extrahead %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/leaflet-coverage@0.7/leaflet-coverage.css">
|
||||
<script src="https://unpkg.com/leaflet@1.3.1/dist/leaflet.js"></script>
|
||||
{% if data.type == "Coverage" or data.type == "CoverageCollection" %}
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/c3@0.7.20/c3.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@5.16.0/dist/d3.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/c3@0.7.20/c3.js"></script>
|
||||
<script src="https://unpkg.com/covutils@0.6/covutils.min.js"></script>
|
||||
<script src="https://unpkg.com/covjson-reader@0.16/covjson-reader.src.js"></script>
|
||||
<script src="https://unpkg.com/leaflet-coverage@0.7/leaflet-coverage.min.js"></script>
|
||||
{% elif data.type == "Feature" or data.type == "FeatureCollection" %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.css"/>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.Default.css"/>
|
||||
<script src="https://unpkg.com/leaflet.markercluster/dist/leaflet.markercluster-src.js"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<section id="coverage">
|
||||
<div id="items-map"></div>
|
||||
{% if data.features or data.coverages or data.ranges or data.references %}
|
||||
<div id="coverages-map"></div>
|
||||
{% else %}
|
||||
<div class="row col-sm-12">
|
||||
<p>{% trans %}No items{% endtrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrafoot %}
|
||||
{% if data %}
|
||||
<script>
|
||||
var map = L.map('items-map').setView([{{ 45 }}, {{ -75 }}], 5);
|
||||
map.addLayer(new L.TileLayer(
|
||||
var map = L.map('coverages-map').setView([40, -85], 3);
|
||||
var baseLayers = {
|
||||
'Map': new L.TileLayer(
|
||||
'{{ config['server']['map']['url'] }}', {
|
||||
maxZoom: 18,
|
||||
attribution: '{{ config['server']['map']['attribution'] | safe }}'
|
||||
}).addTo(map)
|
||||
}
|
||||
|
||||
{% if data.type == "Coverage" or data.type == "CoverageCollection" %}
|
||||
let layerControl = L.control.layers(baseLayers, {}, {collapsed: false}).addTo(map)
|
||||
let layersInControl = new Set()
|
||||
let coverageLayersOnMap = new Set()
|
||||
let paramSync = new C.ParameterSync({
|
||||
syncProperties: {
|
||||
palette: (p1, p2) => p1,
|
||||
paletteExtent: (e1, e2) => e1 && e2 ? [Math.min(e1[0], e2[0]), Math.max(e1[1], e2[1])] : null
|
||||
}
|
||||
}).on('parameterAdd', e => {
|
||||
// The virtual sync layer proxies the synced palette, paletteExtent, and parameter.
|
||||
// The sync layer will fire a 'remove' event if all real layers for that parameter were removed.
|
||||
let layer = e.syncLayer
|
||||
if (layer.palette) {
|
||||
C.legend(layer, {
|
||||
position: 'bottomright'
|
||||
}).addTo(map)
|
||||
}
|
||||
));
|
||||
|
||||
var layers = L.control.layers(null, null, {collapsed: false}).addTo(map)
|
||||
|
||||
CovJSON.read(JSON.parse('{{ data | to_json | safe }}')).then(function (cov) {
|
||||
cov.parameters.forEach((p) => {
|
||||
var layer = C.dataLayer(cov, {parameter: p.key})
|
||||
.on('afterAdd', function () {
|
||||
C.legend(layer).addTo(map)
|
||||
map.fitBounds(layer.getBounds())
|
||||
})
|
||||
.addTo(map)
|
||||
layers.addOverlay(layer, p.observedProperty.label?.en)
|
||||
map.setZoom(5)
|
||||
})
|
||||
|
||||
|
||||
displayCovJSON(JSON.parse('{{ data | to_json | safe }}'), {display: true})
|
||||
|
||||
const truncateString = (str, maxLength) => {
|
||||
str = str.replace(/\+/g, ' ');
|
||||
return str.length > maxLength ? `${str.slice(0, maxLength - 3)}...` : str;
|
||||
};
|
||||
|
||||
function displayCovJSON(obj, options = {}) {
|
||||
map.fire('dataloading');
|
||||
var layer = CovJSON.read(obj)
|
||||
.then(cov => {
|
||||
if (CovUtils.isDomain(cov)) {
|
||||
cov = CovUtils.fromDomain(cov);
|
||||
}
|
||||
|
||||
map.fire('dataload');
|
||||
|
||||
// add each parameter as a layer
|
||||
let firstLayer;
|
||||
|
||||
let layerClazz = C.dataLayerClass(cov);
|
||||
|
||||
if (cov.coverages && !layerClazz) {
|
||||
// generic collection
|
||||
if (!cov.parameters) {
|
||||
throw new Error('only coverage collections with a "parameters" property are supported');
|
||||
}
|
||||
|
||||
for (let key of cov.parameters.keys()) {
|
||||
let layers = cov.coverages
|
||||
.filter(coverage => coverage.parameters.has(key))
|
||||
.map(coverage => createLayer(coverage, { keys: [key] }));
|
||||
layers.forEach(layer => map.fire('covlayercreate', { layer }));
|
||||
let layerGroup = L.layerGroup(layers);
|
||||
layersInControl.add(layerGroup);
|
||||
layerControl.addOverlay(layerGroup, truncateString(key, 50));
|
||||
if (!firstLayer) {
|
||||
firstLayer = layerGroup;
|
||||
// the following piece of code should be easier
|
||||
// TODO extend layer group class in leaflet-coverage (like PointCollection) to provide single 'add' event
|
||||
let addCount = 0;
|
||||
for (let l of layers) {
|
||||
l.on('afterAdd', () => {
|
||||
coverageLayersOnMap.add(l);
|
||||
++addCount;
|
||||
if (addCount === layers.length) {
|
||||
zoomToLayers(layers);
|
||||
// FIXME is this the right place?? define event semantics!
|
||||
map.fire('covlayeradd', { layer: l });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (layerClazz) {
|
||||
// single coverage or a coverage collection of a specific domain type
|
||||
for (let key of cov.parameters.keys()) {
|
||||
let opts = { keys: [key] };
|
||||
let layer = createLayer(cov, opts);
|
||||
map.fire('covlayercreate', { layer });
|
||||
layersInControl.add(layer);
|
||||
|
||||
layerControl.addOverlay(layer, truncateString(key, 50));
|
||||
if (!firstLayer) {
|
||||
firstLayer = layer;
|
||||
layer.on('afterAdd', () => {
|
||||
zoomToLayers([layer])
|
||||
if (!cov.coverages) {
|
||||
if (isVerticalProfile(cov) || isTimeSeries(cov)) {
|
||||
layer.openPopup();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
layer.on('afterAdd', () => {
|
||||
coverageLayersOnMap.add(layer);
|
||||
map.fire('covlayeradd', { layer });
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error('unsupported or missing domain type');
|
||||
}
|
||||
if (options.display && firstLayer) {
|
||||
map.addLayer(firstLayer);
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
map.fire('dataload');
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
|
||||
function createLayer(cov, opts) {
|
||||
let layer = C.dataLayer(cov, opts).on('afterAdd', e => {
|
||||
let covLayer = e.target
|
||||
|
||||
// This registers the layer with the sync manager.
|
||||
// By doing that, the palette and extent get unified (if existing)
|
||||
// and an event gets fired if a new parameter was added.
|
||||
// See the code above where ParameterSync gets instantiated.
|
||||
paramSync.addLayer(covLayer)
|
||||
|
||||
if (!cov.coverages) {
|
||||
if (covLayer.time) {
|
||||
new C.TimeAxis(covLayer).addTo(map)
|
||||
}
|
||||
if (covLayer.vertical) {
|
||||
new C.VerticalAxis(covLayer).addTo(map)
|
||||
}
|
||||
}
|
||||
}).on('dataLoad', () => map.fire('dataload'))
|
||||
.on('dataLoading', () => map.fire('dataloading'))
|
||||
.on('error', e => map.fire('error', { error: e.error }))
|
||||
layer.on('axisChange', () => {
|
||||
layer.paletteExtent = 'subset'
|
||||
})
|
||||
|
||||
if (cov.coverages) {
|
||||
if (isVerticalProfile(cov)) {
|
||||
layer.bindPopupEach(coverage => new C.VerticalProfilePlot(coverage))
|
||||
} else if (isTimeSeries(cov)) {
|
||||
layer.bindPopupEach(coverage => new C.TimeSeriesPlot(coverage))
|
||||
}
|
||||
} else {
|
||||
if (isVerticalProfile(cov)) {
|
||||
layer.bindPopup(new C.VerticalProfilePlot(cov))
|
||||
} else if (isTimeSeries(cov)) {
|
||||
layer.bindPopup(new C.TimeSeriesPlot(cov))
|
||||
}
|
||||
}
|
||||
|
||||
return layer
|
||||
}
|
||||
|
||||
function zoomToLayers (layers) {
|
||||
let bnds = layers.map(l => l.getBounds())
|
||||
let bounds = L.latLngBounds(bnds)
|
||||
let opts = {
|
||||
padding: L.point(10, 10)
|
||||
}
|
||||
if (bounds.getWest() === bounds.getEast() && bounds.getSouth() === bounds.getNorth()) {
|
||||
opts.maxZoom = 5
|
||||
}
|
||||
map.fitBounds(bounds, opts)
|
||||
}
|
||||
|
||||
function isVerticalProfile (cov) {
|
||||
return cov.domainType === C.COVJSON_VERTICALPROFILE
|
||||
}
|
||||
|
||||
function isTimeSeries (cov) {
|
||||
return cov.domainType === C.COVJSON_POINTSERIES || cov.domainType === C.COVJSON_POLYGONSERIES
|
||||
}
|
||||
window.api = {
|
||||
map,
|
||||
layers: coverageLayersOnMap
|
||||
}
|
||||
|
||||
// Wire up coverage value popup
|
||||
let valuePopup = new C.DraggableValuePopup({
|
||||
className: 'leaflet-popup-draggable',
|
||||
layers: [...coverageLayersOnMap]
|
||||
})
|
||||
|
||||
map.on('click', function (e) {
|
||||
new C.DraggableValuePopup({
|
||||
layers: [layer]
|
||||
}).setLatLng(e.latlng).openOn(map)
|
||||
function closeValuePopup () {
|
||||
if (map.hasLayer(valuePopup)) {
|
||||
map.closePopup(valuePopup)
|
||||
}
|
||||
}
|
||||
|
||||
// click event needed for Grid layer (can't use bindPopup there)
|
||||
map.on('singleclick', e => {
|
||||
valuePopup.setLatLng(e.latlng).openOn(map)
|
||||
})
|
||||
map.on('covlayercreate', e => {
|
||||
// some layers already have a plot popup bound to it, ignore those
|
||||
if (!e.layer.getPopup()) {
|
||||
e.layer.bindPopup(valuePopup)
|
||||
}
|
||||
})
|
||||
map.on('covlayeradd', e => {
|
||||
valuePopup.addCoverageLayer(e.layer)
|
||||
})
|
||||
map.on('covlayerremove', e => {
|
||||
valuePopup.removeCoverageLayer(e.layer)
|
||||
})
|
||||
|
||||
map.on('error', e => {
|
||||
if (e.error?.message) {
|
||||
editor.setError(e.error.message)
|
||||
}
|
||||
})
|
||||
{% elif data.type == "Feature" or data.type == "FeatureCollection" %}
|
||||
var geojson_data = {{ data | to_json | safe }};
|
||||
|
||||
var items = new L.GeoJSON(geojson_data, {
|
||||
onEachFeature: function (feature, layer) {
|
||||
var html = '<span>' + {% if data['title_field'] %} feature['properties']['{{ data['title_field'] }}'] {% else %} feature.id {% endif %} + '</span>';
|
||||
layer.bindPopup(html);
|
||||
}
|
||||
});
|
||||
var markers = L.markerClusterGroup({
|
||||
disableClusteringAtZoom: 9,
|
||||
chunkedLoading: true,
|
||||
chunkInterval: 500,
|
||||
});
|
||||
markers.clearLayers().addLayer(items);
|
||||
map.addLayer(markers);
|
||||
map.fitBounds(items.getBounds(), {maxZoom: 15});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,755 +1,183 @@
|
||||
{% extends "_base.html" %}
|
||||
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
|
||||
{% block crumbs %}{{ super() }}
|
||||
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
|
||||
{% for link in data['links'] %}
|
||||
{% if link.rel == 'collection' %} /
|
||||
<a href="{{ data['dataset_path'] }}">{{ link['title'] | string | truncate( 25 ) }}</a>
|
||||
{% set col_title = link['title'] %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
/ <a href="{{ data['items_path']}}">{% trans %}Items{% endtrans %}</a>
|
||||
{% endblock %}
|
||||
{% block extrahead %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css"/>
|
||||
<script src="https://unpkg.com/leaflet@1.3.1/dist/leaflet.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.css"/>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.Default.css"/>
|
||||
<script src="https://unpkg.com/leaflet.markercluster/dist/leaflet.markercluster-src.js"></script>
|
||||
|
||||
<script src="https://unpkg.com/deck.gl@latest/dist.min.js"></script>
|
||||
<script src="https://cdn.maptiler.com/maptiler-sdk-js/v2.2.2/maptiler-sdk.umd.js"></script>
|
||||
<link href="https://cdn.maptiler.com/maptiler-sdk-js/v2.2.2/maptiler-sdk.css" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block body_map %}
|
||||
|
||||
|
||||
<div class="row">
|
||||
{% if data['speckle_url'] %}
|
||||
|
||||
|
||||
<div id="map2d" style="height: 80vh;"></div>
|
||||
<div id="map3d" style="height: 0vh;"></div>
|
||||
{% else %}
|
||||
|
||||
<div class="form-group" >
|
||||
<label class="switch">3D
|
||||
<input id="modeSwitch" type="checkbox" disabled="true">
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="map2d" style="height: 40vh;"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
<section id="description">
|
||||
<div class="row">
|
||||
<p> </p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<tr>
|
||||
<p>
|
||||
This is the test deployment of the OGC API server for public Speckle projects.
|
||||
It allows you to share your Speckle model as geospatial data in the format of
|
||||
OGC API Features / Web Feature Service, so it can be natively added to a QGIS, ArcGIS
|
||||
or Civil3D project, or embedded into a web map using Leaflet, OpenLayers or other libraries.
|
||||
You can find more guidelines and examples on our <a href = "https://github.com/specklesystems/pygeoapi/tree/dev" target="_blank">GitHub page</a>.
|
||||
</p>
|
||||
</tr>
|
||||
|
||||
{% if not data['speckle_url'] %}
|
||||
<tr>
|
||||
<p>
|
||||
<div style="height: fit-content;">
|
||||
<p> Provide Speckle Model link as an argument to start exploring, e.g.: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828</a></p>
|
||||
</div>
|
||||
</p>
|
||||
</tr>
|
||||
|
||||
{% else %}
|
||||
<details>
|
||||
<summary><b>Details of the current Speckle model</b></summary>
|
||||
|
||||
<section id="url_parameters">
|
||||
{% if data['features'] %}
|
||||
|
||||
<div class="row">
|
||||
<p> </p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<p>{% trans %}Note: if Speckle model location data is available, the relevant URL parameters will be ignored. If neither model nor URL parameters specify the location, model will be placed randomly. {% endtrans %}</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<b>{% trans %}Speckle model data {% endtrans %}</b>
|
||||
<div style="overflow-x: scroll;">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td >Project name:</td>
|
||||
<td>{{ data['project'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td >Model name:</td>
|
||||
<td>{{ data['model'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td >Last version created:</td>
|
||||
<td>{{ data['model_last_version_date'] }}</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td >Coordinate Reference System:</td>
|
||||
<td>{{ data['model_crs'] }}</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<b>{% trans %}URL parameters {% endtrans %}</b>
|
||||
<div style="overflow-x: scroll;">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td >Speckle URL ('speckleurl')</td>
|
||||
<td>
|
||||
<a href="{{ data['speckle_url'] }}">
|
||||
{{ data['speckle_url'] }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Requested data type ('datatype')</td>
|
||||
<td>{{ data['requested_data_type'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Prioritize object attributes over display quality ('preserveattributes')</td>
|
||||
<td>{{ data['preserve_attributes'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td >Coordinate Reference System ID ('crsauthid')</td>
|
||||
<td>{{ data['crs_authid'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Latitide ('lat')</td>
|
||||
<td>{{ data['lat'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Longitude ('lon')</td>
|
||||
<td>{{ data['lon'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td >Angle to True North, in degrees ('northdegrees')</td>
|
||||
<td>{{ data['north_degrees'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td >Feature limit ('limit')</td>
|
||||
<td>{{ data['limit'] }}</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<p> </p>
|
||||
</div>
|
||||
|
||||
|
||||
</section>
|
||||
|
||||
<section id="items">
|
||||
|
||||
<div class="row">
|
||||
<p> </p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<b>{% trans %}Features in this model {% endtrans %}</b>
|
||||
<div style="overflow-x: scroll;">
|
||||
{% set props = [] %}
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if data.get('uri_field') %}
|
||||
{% set uri_field = data.uri_field %}
|
||||
<th>{{ uri_field }}</th>
|
||||
{% elif data.get('title_field') %}
|
||||
{% set title_field = data.title_field %}
|
||||
<th>{{ title_field }}</th>
|
||||
{% else %}
|
||||
<th>id</th>
|
||||
{% endif %}
|
||||
|
||||
{% for k in data['features'][0]['properties'].keys() %}
|
||||
{% if k not in [data.id_field, data.title_field, data.uri_field, 'extent'] %}
|
||||
{% set props = props.append(k) %}
|
||||
<th>{{ k | striptags }}</th>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ft in data.features %}
|
||||
<tr>
|
||||
{% set title_field = data.title_field %}
|
||||
<td data-label="{{ title_field }}">
|
||||
<a title="{{ ft.properties.get(title_field) }}" href="{{data['speckle_url'].split('/models')[0]}}/models/{{ft.id.split('_')[0]}}" target="_blank">
|
||||
{{ ft.properties.get(title_field) | string | truncate( 35 ) }}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
{% for prop in props %}
|
||||
<td data-label="{{ prop }}">
|
||||
{{ ft.properties.get(prop, '') | string | truncate( 35 ) }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<p>{% trans %}No items{% endtrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<tr><p></p></tr>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<tr>
|
||||
<p>
|
||||
You can use the current link to access OGC API dataset in your preferred software,
|
||||
as well as explore the data in the browser and share with others. URL should start with 'https://geo.speckle.systems/?'
|
||||
followed by required and optional parameters. Parameters should be separated with '&' symbol.
|
||||
Use the following URL parameters to construct a link that provides Speckle data with your preferred settings:
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<i>- speckleUrl</i><p> text, required, should contain path to a specific Model in Speckle Project, e.g. 'https://app.speckle.systems/projects/344f803f81/models/5582ab673e'
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<i>- dataType</i><p> text, optional, choose from: points, lines, polygons or projectcomments
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<i>- limit</i><p> positive integer, recommended, as some applications might apply their custom feature limit
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<i>- preserveAttributes</i><p> string, optional, choose from: true, false. If not set, meshes will be split into separate polygons for better display quality.
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<i>- crsAuthid</i><p> text, an authority string e.g. 'epsg:4326'. If set, LAT, LON and NORTHDEGREES arguments will be ignored.
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<i>- lat</i><p> number, in range -90 to 90
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<i>- lon</i><p> number, in range -180 to 180
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<i>- northDegrees</i><p> number, in range -180 to 180
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<p>
|
||||
If GIS-originated Speckle model is loaded, no location arguments are needed.
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
</tr>
|
||||
|
||||
</tr><p></p>
|
||||
<tr>
|
||||
|
||||
<tr>
|
||||
<p>
|
||||
Here are some examples:
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<p>
|
||||
1. QGIS polygon features: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=polygons&preserveAttributes=true">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=polygons&preserveAttributes=true</a>
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<p>
|
||||
2. QGIS point features: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/8c49788b1f&datatype=points&preserveAttributes=true">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/8c49788b1f&datatype=points&preserveAttributes=true</a>
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<p>
|
||||
3. Rhino building masses: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828</a>
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<p>
|
||||
4. Rhino detailed building: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/5feae56049/models/9c43d7569c&limit=100000&northDegrees=-117">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/5feae56049/models/9c43d7569c&limit=100000&northDegrees=-117</a>
|
||||
</p>
|
||||
</tr>
|
||||
<tr>
|
||||
<p>
|
||||
5. Speckle project comments: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=projectcomments">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=projectcomments</a>
|
||||
</p>
|
||||
</tr>
|
||||
|
||||
</tr><p></p>
|
||||
<tr>
|
||||
|
||||
<tr>
|
||||
<p>
|
||||
<b>Note: this is not a production server.</b> It is still work in progress,
|
||||
and we are very curious
|
||||
to <a href = "https://speckle.community/invites/qxEmQb1QcM" target="_blank">hear about your use case and your feedback</a>
|
||||
so we can make it better!
|
||||
</p>
|
||||
</tr>
|
||||
</div>
|
||||
<section id="items"></section>
|
||||
<section id="collection">
|
||||
<h1>{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% endfor %}</h1>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="items">
|
||||
{% if data['features'] %}
|
||||
<div class="row">
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div id="items-map"></div>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
{% if data['numberMatched'] %}
|
||||
<p>{% trans %}Items in this collection{% endtrans %}: {{ data['numberMatched'] }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
{% trans %}Limit{% endtrans %}:
|
||||
<select id="limits">
|
||||
<option value="{{ config['server']['limit'] }}">{{ config['server']['limit'] }} ({% trans %}default{% endtrans %})</option>
|
||||
<option value="100">100</option>
|
||||
<option value="1000">1,000</option>
|
||||
<option value="2000">2,000</option>
|
||||
</select>
|
||||
<p>{% trans %}Warning: Higher limits not recommended!{% endtrans %}</p>
|
||||
<script>
|
||||
var select = document.getElementById('limits');
|
||||
var defaultValue = select.getElementsByTagName('option')[0].value;
|
||||
let params = (new URL(document.location)).searchParams;
|
||||
select.value = params.get('limit') || defaultValue;
|
||||
select.addEventListener('change', ev => {
|
||||
var limit = ev.target.value;
|
||||
document.location.search = `limit=${limit}`;
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
{% for link in data['links'] %}
|
||||
{% if link['rel'] == 'prev' and data['offset'] > 0 %}
|
||||
<a role="button" href="{{ link['href'] }}">{% trans %}Prev{% endtrans %}</a>
|
||||
{% elif link['rel'] == 'next' and data['features'] %}
|
||||
<a role="button" href="{{ link['href'] }}">{% trans %}Next{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 col-md-6" style="overflow-x: scroll;">
|
||||
{% set props = [] %}
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if data.get('uri_field') %}
|
||||
{% set uri_field = data.uri_field %}
|
||||
<th>{{ uri_field }}</th>
|
||||
{% elif data.get('title_field') %}
|
||||
{% set title_field = data.title_field %}
|
||||
<th>{{ title_field }}</th>
|
||||
{% else %}
|
||||
<th>id</th>
|
||||
{% endif %}
|
||||
|
||||
{% for k in data['features'][0]['properties'].keys() %}
|
||||
{% if k not in [data.id_field, data.title_field, data.uri_field, 'extent'] %}
|
||||
{% set props = props.append(k) %}
|
||||
<th>{{ k | striptags }}</th>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ft in data.features %}
|
||||
<tr>
|
||||
{% if data.get('uri_field') %}
|
||||
{% set uri_field = data.uri_field %}
|
||||
<td data-label="{{ uri_field }}">
|
||||
<a href="{{ ft.properties.get(uri_field) }}" title="{{ ft.properties.get(uri_field) }}">
|
||||
{{ ft.properties.get(uri_field) }}
|
||||
</a>
|
||||
</td>
|
||||
{% elif data.get('title_field') %}
|
||||
{% set title_field = data.title_field %}
|
||||
<td data-label="{{ title_field }}">
|
||||
<a href="{{ data.items_path }}/{{ ft['id'] }}" title="{{ ft.properties.get(title_field) }}">
|
||||
{{ ft.properties.get(title_field) | string | truncate( 35 ) }}
|
||||
</a>
|
||||
</td>
|
||||
{% else %}
|
||||
<td data-label="id">
|
||||
<a href="{{ data.items_path }}/{{ ft.id }}" title="{{ ft.id }}">
|
||||
{{ ft.id | string | truncate( 12 ) }}
|
||||
</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% for prop in props %}
|
||||
<td data-label="{{ prop }}">
|
||||
{{ ft.properties.get(prop, '') | string | truncate( 35 ) }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% elif data['numberMatched'] %}
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<p>{% trans %}Items in this collection{% endtrans %}: {{ data['numberMatched'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row col-sm-12">
|
||||
<p>{% trans %}No items{% endtrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrafoot %}
|
||||
|
||||
{% if data['features'] %}
|
||||
<script>
|
||||
try {
|
||||
document.getElementById("loading_screen").remove();
|
||||
document.getElementById("loading_screen_band").remove();
|
||||
}
|
||||
catch(err) {}
|
||||
|
||||
// attach even to modeSwitch btn
|
||||
document.getElementById("modeSwitch").onclick = switchMode;
|
||||
var el = document.getElementById("modeSwitch");
|
||||
if (el.addEventListener)
|
||||
el.addEventListener("click", switchMode, false);
|
||||
else if (el.attachEvent)
|
||||
el.attachEvent('onclick', switchMode);
|
||||
|
||||
function switchMode() {
|
||||
btn = document.getElementById('modeSwitch');
|
||||
if (btn.checked){
|
||||
document.getElementById('map2d').style.height = '0vh';
|
||||
document.getElementById('map3d').style.height = '80vh';
|
||||
var map = L.map('items-map').setView([{{ 45 }}, {{ -75 }}], 5);
|
||||
map.addLayer(new L.TileLayer(
|
||||
'{{ config['server']['map']['url'] }}', {
|
||||
maxZoom: 18,
|
||||
attribution: '{{ config['server']['map']['attribution'] | safe }}'
|
||||
}
|
||||
else {
|
||||
document.getElementById('map2d').style.height = '80vh';
|
||||
document.getElementById('map3d').style.height = '0vh';
|
||||
));
|
||||
var geojson_data = {{ data['features'] | to_json | safe }};
|
||||
|
||||
var items = new L.GeoJSON(geojson_data, {
|
||||
onEachFeature: function (feature, layer) {
|
||||
var url = '{{ data['items_path'] }}/' + feature.id + '?f=html';
|
||||
var html = '<span><a href="' + url + '">' + {% if data['title_field'] %} feature['properties']['{{ data['title_field'] }}'] {% else %} feature.id {% endif %} + '</a></span>';
|
||||
layer.bindPopup(html);
|
||||
}
|
||||
}
|
||||
|
||||
function split_polygons(geojson_data){
|
||||
features = []
|
||||
geojson_data.forEach((element, index) => {
|
||||
if (element.geometry.type == "MultiPolygon"){
|
||||
// mesh faces are stored as parts, so most buildings might have hundreds of parts with just 1 loop or 3-4pts
|
||||
all_parts = element.geometry.coordinates;
|
||||
|
||||
// loops are usually simple (3-4 vertices), and usually only loop
|
||||
for (polyPart of all_parts){
|
||||
new_coordinates = [polyPart];
|
||||
new_element = {"id": element.properties.id, "type":"Feature",
|
||||
"geometry": {"type": "MultiPolygon", "coordinates": new_coordinates},
|
||||
"properties": element.properties,
|
||||
"displayProperties": element.displayProperties };
|
||||
|
||||
features.push(new_element)
|
||||
}
|
||||
}
|
||||
else {
|
||||
features.push(element)
|
||||
}
|
||||
});
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
var data = {{ data | to_json | safe }};
|
||||
var geojson_data_original = {{ data['features'] | to_json | safe }};
|
||||
|
||||
// Leaflet 2d map
|
||||
function initialize2d() {
|
||||
var map = L.map('map2d', {zoomControl: false}).setView([45, 0], 2);
|
||||
L.control.zoom({
|
||||
position: 'topright'
|
||||
}).addTo(map);
|
||||
var tileLayer = new L.TileLayer(
|
||||
'{{ config['server']['map']['url'] }}', {
|
||||
maxZoom: 22,
|
||||
minZoom: 12,
|
||||
attribution: '{{ config['server']['map']['attribution'] | safe }} © Data: <a href="https://speckle.systems/">Speckle Systems</a>'
|
||||
}
|
||||
);
|
||||
|
||||
geojson_data = split_polygons(geojson_data_original);
|
||||
project_url = ""
|
||||
try {
|
||||
project_url = data['speckle_url'].split("/models")[0]
|
||||
}
|
||||
catch(err) {}
|
||||
|
||||
|
||||
var items = new L.GeoJSON(geojson_data, {
|
||||
filter: (feature) => {
|
||||
return feature.displayProperties["object_type"] == "geometry"
|
||||
},
|
||||
pointToLayer: (feature, latlng) => {
|
||||
return new L.circleMarker(latlng)
|
||||
},
|
||||
onEachFeature: function (feature, layer) {
|
||||
var url = project_url + '/models/' + feature.id.split("_")[0]
|
||||
var html = '<span><td><p>' + feature['properties']['speckle_type'] + '</p></td><a href="' + url + '" target="_blank">' + feature['properties']['id'].split("_")[0] + '</a></span>';
|
||||
layer.bindPopup(html);
|
||||
|
||||
var myFillColor = feature.displayProperties['color'];
|
||||
var mylineWeight = feature.displayProperties['lineWidth'];
|
||||
var myRadius = feature.displayProperties['radius'];
|
||||
|
||||
layer.setStyle({
|
||||
fillColor: myFillColor,
|
||||
color: myFillColor,
|
||||
fillOpacity: 0.8,
|
||||
weight: mylineWeight,
|
||||
radius: myRadius
|
||||
});
|
||||
}
|
||||
}); //.addTo(map);
|
||||
|
||||
var comments = new L.GeoJSON(geojson_data, {
|
||||
filter: (feature) => {
|
||||
return feature.displayProperties["object_type"] == "comment"
|
||||
},
|
||||
pointToLayer: (feature, latlng) => {
|
||||
return new L.marker(latlng)
|
||||
},
|
||||
onEachFeature: function (feature, layer) {
|
||||
var url = project_url + '/models/' + feature.properties.resource_id + '#threadId=' + feature.id;
|
||||
var html = '<span><td><a href="' + url + '" target="_blank">Go to thread</a></td> <td><p>' + feature['properties']['text_html'] + '</p></td> </span>';
|
||||
layer.bindPopup(html);
|
||||
}
|
||||
}); //.addTo(map);
|
||||
|
||||
|
||||
var group = L.featureGroup([items, comments]);
|
||||
// load proper basemap for Speckle models; but only zoomed-out one for empty data
|
||||
try
|
||||
{
|
||||
bounds = group.getBounds();
|
||||
map.fitBounds(bounds);
|
||||
|
||||
tileLayer.addTo(map);
|
||||
group.addTo(map);
|
||||
}
|
||||
catch (err){
|
||||
tileLayer = new L.TileLayer(
|
||||
'{{ config['server']['map']['url'] }}', {
|
||||
maxZoom: 2,
|
||||
minZoom: 2,
|
||||
attribution: '{{ config['server']['map']['attribution'] | safe }} © Data: <a href="https://speckle.systems/">Speckle Systems</a>'
|
||||
}
|
||||
);
|
||||
tileLayer.addTo(map);
|
||||
}
|
||||
|
||||
|
||||
|
||||
//map.addLayer(lines);
|
||||
// map.setZoom(19); // in order for the tiles to load
|
||||
};
|
||||
|
||||
|
||||
// MapTiler 3d map
|
||||
function hexToRgb(hex) {
|
||||
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16), parseInt(result[0], 16)] : null;
|
||||
};
|
||||
function rgbaToArgbList(color) {
|
||||
if (color == null){
|
||||
return [10,132,255,255];
|
||||
}
|
||||
col = color.replace('rgba(','').replace(')','').split(',',4);
|
||||
value = [ parseInt(col[0]), parseInt(col[1]), parseInt(col[2]), parseInt(col[3]) ];
|
||||
return value;
|
||||
}
|
||||
function rgbaToArgbListDarker(color) {
|
||||
if (color == null){
|
||||
return [7,120,235,255];
|
||||
}
|
||||
col = color.replace('rgba(','').replace(')','').split(',',4);
|
||||
value = [ parseInt(col[0])*0.95, parseInt(col[1])*0.95, parseInt(col[2])*0.95, parseInt(col[3]) ];
|
||||
return value;
|
||||
}
|
||||
function getMessagesNumber(properties)
|
||||
{
|
||||
if (properties == null || properties.messages == null) {return 1}
|
||||
else
|
||||
{
|
||||
return properties.messages.length
|
||||
}
|
||||
}
|
||||
function getDisplayText(properties)
|
||||
{
|
||||
if (properties != null && properties.text_html != null){
|
||||
return properties.text_html
|
||||
}
|
||||
else if (properties != null && properties.speckle_type != null && properties.id != null){
|
||||
return `${properties.speckle_type}: ${properties.id.split("_")[0]}`
|
||||
}
|
||||
else{
|
||||
return ``
|
||||
}
|
||||
}
|
||||
// Callback to populate the default tooltip with content
|
||||
function getTooltip({object}) {
|
||||
return object && {
|
||||
html: getDisplayText(object.properties),
|
||||
style: {
|
||||
backgroundColor: 'rgb(255,255,255)',
|
||||
color: 'rgb(0,0,0)',
|
||||
fontSize: '0.9em'
|
||||
}
|
||||
};
|
||||
}
|
||||
function createSVGIcon(n) {
|
||||
const label = n < 10 ? n.toString() : '10+';
|
||||
return `\
|
||||
<svg width="100" height="100" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgb(10,132,255)" stroke="rgb(200,200,200)" stroke-width="2"/>
|
||||
<text x="12" y="12" fill="#fff" text-anchor="middle" alignment-baseline="middle" font-family="verdana" font-size="8">${label}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// Note that a xml string cannot be directly embedded in a data URL
|
||||
// it has to be either escaped or converted to base64.
|
||||
function svgToDataUrl(svg) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
// You may need base64 encoding if the SVG contains certain special characters
|
||||
function svgToDataUrlBase64(svg) {
|
||||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||
}
|
||||
|
||||
|
||||
function initialize3d() {
|
||||
maptilersdk.config.apiKey = '{{ config["server"]["map"]["key"] }}';
|
||||
speckle_data = JSON.parse(JSON.stringify(data))
|
||||
|
||||
var speckle_features = []
|
||||
for (let i = 0; i < speckle_data.features.length; i++) {
|
||||
feature = speckle_data.features[i];
|
||||
coords = feature.geometry.coordinates;
|
||||
|
||||
if (feature.geometry.type.includes("Polygon")) {
|
||||
|
||||
polygon_all_parts = []
|
||||
// iterate through Polygon Parts
|
||||
for (let p = 0; p < coords.length; p++) {
|
||||
|
||||
// check orientation of each PolygonPart, if vertical - shift points slightly
|
||||
polygon_part = [];
|
||||
inner = false;
|
||||
|
||||
for (let c = 0; c < coords[p].length; c++) {
|
||||
polygon_part_loop = [];
|
||||
if (c>0){
|
||||
inner = true;
|
||||
}
|
||||
|
||||
sum_orientation = 0;
|
||||
polygon_pts = coords[p][c]; // usually 3 for Mesh faces
|
||||
for (let k = 0; k < polygon_pts.length; k++){
|
||||
index = k + 1
|
||||
if (k == polygon_pts.length - 1){index = 0}
|
||||
pt = polygon_pts[k]
|
||||
pt2 = polygon_pts[index]
|
||||
sum_orientation += (pt2[0] - pt[0]) * (pt2[1] + pt[1])
|
||||
};
|
||||
|
||||
createdPolygon = false;
|
||||
|
||||
if (-0.000000001 < sum_orientation && sum_orientation <0.000000001){
|
||||
|
||||
coords[p][c][0][0] += 0.0000001;
|
||||
coords[p][c][0][1] += 0.0000001;
|
||||
|
||||
coords[p][c][1][0] -= 0.0000001;
|
||||
coords[p][c][1][1] -= 0.0000001;
|
||||
|
||||
coords[p][c][2][0] += 0.0000001;
|
||||
coords[p][c][2][1] += 0.0000001;
|
||||
|
||||
if(polygon_pts.length==3) {
|
||||
createdPolygon = true;
|
||||
|
||||
multipolygon_coords = coords[p][c];
|
||||
polygon_part_loop = multipolygon_coords;
|
||||
|
||||
polygon_part = [polygon_part_loop];
|
||||
polygon_all_parts.push(polygon_part);
|
||||
|
||||
}
|
||||
else if (polygon_pts.length==4) {
|
||||
createdPolygon = true;
|
||||
|
||||
multipolygon_coords = coords[p][c].slice(0,3);
|
||||
polygon_part_loop = multipolygon_coords;
|
||||
|
||||
polygon_part = [polygon_part_loop];
|
||||
polygon_all_parts.push(polygon_part);
|
||||
|
||||
/////////
|
||||
multipolygon_coords = [coords[p][c][2], coords[p][c][3], coords[p][c][0]];
|
||||
polygon_part_loop = multipolygon_coords;
|
||||
|
||||
polygon_part = [polygon_part_loop];
|
||||
polygon_all_parts.push(polygon_part);
|
||||
};
|
||||
|
||||
};
|
||||
if (createdPolygon == false){ // if non-vertical, or vertical with more than 4 vertices
|
||||
|
||||
multipolygon_coords = coords[p][c];
|
||||
polygon_part_loop = multipolygon_coords;
|
||||
|
||||
if (inner == false){
|
||||
polygon_part = [polygon_part_loop];
|
||||
polygon_all_parts.push(polygon_part);
|
||||
}
|
||||
else{
|
||||
polygon_all_parts[polygon_all_parts.length-1].push(polygon_part_loop);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
new_polygon = {"id": speckle_features.length, "type":"Feature",
|
||||
"geometry": {"type": "MultiPolygon", "coordinates":polygon_all_parts},
|
||||
"properties": speckle_data.features[i].properties,
|
||||
"displayProperties": speckle_data.features[i].displayProperties };
|
||||
|
||||
new_polygon.displayProperties.lineWidth = 0.05
|
||||
speckle_features.push(new_polygon);
|
||||
|
||||
}
|
||||
else if (speckle_data.features[i].displayProperties.object_type == "comment")
|
||||
{
|
||||
speckle_features.push({"id": speckle_features.length, "type":"Feature",
|
||||
"geometry": {"type": speckle_data.features[i].geometry.type, "coordinates":speckle_data.features[i].geometry.coordinates},
|
||||
"properties": speckle_data.features[i].properties,
|
||||
"displayProperties":
|
||||
{
|
||||
"color": 'rgba(10,132,255,255)',
|
||||
"lineWidth": 2,
|
||||
"radius": 10,
|
||||
"object_type": "comments",
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
speckle_features.push({"id": speckle_features.length, "type":"Feature",
|
||||
"geometry": {"type": speckle_data.features[i].geometry.type, "coordinates":speckle_data.features[i].geometry.coordinates},
|
||||
"properties": speckle_data.features[i].properties,
|
||||
"displayProperties": speckle_data.features[i].displayProperties });
|
||||
}
|
||||
}
|
||||
|
||||
var extent = speckle_data["extent"];
|
||||
|
||||
speckle_data.features = [];
|
||||
speckle_features.forEach((element, index, array) => {
|
||||
if (element.displayProperties.object_type != "comments"){
|
||||
speckle_data.features.push(element);
|
||||
}
|
||||
});
|
||||
|
||||
speckle_comments = [];
|
||||
speckle_features.forEach((element, index, array) => {
|
||||
if (element.displayProperties.object_type == "comments"){
|
||||
speckle_comments.push(element);
|
||||
}
|
||||
});
|
||||
|
||||
// Create deck.gl map
|
||||
const deckgl = new deck.DeckGL({
|
||||
container: 'map3d',
|
||||
map: maptilersdk,
|
||||
mapStyle: maptilersdk.MapStyle.STREETS.PASTEL,
|
||||
initialViewState: {
|
||||
longitude: extent[0] + (extent[2]-extent[0])/2,
|
||||
latitude: extent[1] + (extent[3]-extent[1])/2,
|
||||
zoom: 22,
|
||||
minZoom: 12,
|
||||
pitch: 60,
|
||||
bearing: 1.469387755102039
|
||||
},
|
||||
controller: true,
|
||||
|
||||
layers: [
|
||||
|
||||
new deck.GeoJsonLayer({
|
||||
id: 'speckle_data',
|
||||
data: speckle_data,
|
||||
// Styles
|
||||
filled: true,
|
||||
getFillColor: f => rgbaToArgbList(f.displayProperties.color),
|
||||
getLineWidth: f => f.displayProperties.lineWidth,
|
||||
getLineColor: f => rgbaToArgbListDarker(f.displayProperties.color),
|
||||
getPointRadius: f => f.displayProperties.radius / 2,
|
||||
|
||||
// Interactive props
|
||||
pickable: true,
|
||||
autoHighlight: true,
|
||||
}),
|
||||
|
||||
new deck.IconLayer({
|
||||
id: 'IconLayer',
|
||||
data: speckle_comments,
|
||||
// getColor: d => [Math.sqrt(getMessagesNumber(d.properties)), 140, 0],
|
||||
|
||||
getIcon: d => ({
|
||||
url: svgToDataUrl(createSVGIcon( getMessagesNumber(d.properties).toString() )), //'https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/icon-atlas.png',
|
||||
width: 128,
|
||||
height: 128
|
||||
}),
|
||||
getPosition: d => d.geometry.coordinates[0],
|
||||
getSize: f => 50 * Math.pow(getMessagesNumber(f.properties),0.3),
|
||||
pickable: true
|
||||
})
|
||||
],
|
||||
getTooltip
|
||||
});
|
||||
}
|
||||
|
||||
initialize2d();
|
||||
initialize3d();
|
||||
});
|
||||
var markers = L.markerClusterGroup({
|
||||
disableClusteringAtZoom: 9,
|
||||
chunkedLoading: true,
|
||||
chunkInterval: 500,
|
||||
});
|
||||
markers.clearLayers().addLayer(items);
|
||||
map.addLayer(markers);
|
||||
|
||||
map.fitBounds(items.getBounds());
|
||||
</script>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "_base.html" %}
|
||||
{% set ptitle = data['properties'][data['title_field']] or '_(Item) '.format(data['id']) %}
|
||||
{% set ptitle = data['properties'][data['title_field']] or data['id'] | string %}
|
||||
{% block desc %}{{ data.get('properties',{}).get('description', {}) | string | truncate(250) }}{% endblock %}
|
||||
{% block tags %}{{ data['properties'].get('themes', [{}])[0].get('concepts', []) | join(',') }}{% endblock %}
|
||||
{# Optionally renders an img element, otherwise standard value or link rendering #}
|
||||
@@ -125,7 +125,7 @@
|
||||
var map = L.map('items-map').setView([{{ 45 }}, {{ -75 }}], 10);
|
||||
map.addLayer(new L.TileLayer(
|
||||
'{{ config['server']['map']['url'] }}', {
|
||||
maxZoom: 22,
|
||||
maxZoom: 18,
|
||||
attribution: '{{ config['server']['map']['attribution'] | safe }}'
|
||||
}
|
||||
));
|
||||
@@ -133,6 +133,6 @@
|
||||
var items = new L.GeoJSON(geojson_data);
|
||||
|
||||
map.addLayer(items);
|
||||
map.fitBounds(items.getBounds(), {maxZoom: 18});
|
||||
map.fitBounds(items.getBounds(), {maxZoom: 15});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
|
||||
{% block crumbs %}{{ super() }}
|
||||
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
|
||||
/ <a href="./{{ data['id'] }}">{{ data['title'] | truncate( 25 ) }}</a>
|
||||
/ <a href="./{{ data['id'] }}queryables">{% trans %}Queryables{% endtrans %}</a>
|
||||
/ <a href="{{ data['dataset_path'] }}">{{ data['title'] | truncate( 25 ) }}</a>
|
||||
/ <a href="{{ data['dataset_path'] }}/queryables">{% trans %}Queryables{% endtrans %}</a>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<section id="collection">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user