Skip to content

Commit

Permalink
Merge pull request #377 from OpenSPP/govstack_api_gis
Browse files Browse the repository at this point in the history
Govstack api spp_base_gis
  • Loading branch information
gonzalesedwin1123 committed May 28, 2024
2 parents a139d8d + 66f2b8a commit cae6435
Show file tree
Hide file tree
Showing 29 changed files with 2,959 additions and 42 deletions.
4 changes: 3 additions & 1 deletion spp_base_gis/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from . import models
from . import controllers
from . import expression
from . import models
from . import fields
from . import operators

from odoo import _
from odoo.exceptions import MissingError
Expand Down
2 changes: 1 addition & 1 deletion spp_base_gis/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"website": "https://github.com/OpenSPP/openspp-modules",
"license": "LGPL-3",
"development_status": "Beta",
"maintainers": ["jeremi", "gonzalesedwin1123"],
"maintainers": ["jeremi", "gonzalesedwin1123", "reichie020212"],
"depends": ["base", "web", "contacts"],
"external_dependencies": {"python": ["shapely", "pyproj", "geojson"]},
"data": [
Expand Down
54 changes: 54 additions & 0 deletions spp_base_gis/expression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from odoo.models import BaseModel
from odoo.osv import expression
from odoo.osv.expression import TERM_OPERATORS
from odoo.tools import SQL

from .fields import GeoField
from .operators import Operator

GIS_OPERATORS = list(Operator.OPERATION_TO_RELATION.keys())

term_operators_list = list(TERM_OPERATORS)
for op in GIS_OPERATORS:
term_operators_list.append(op)

expression.TERM_OPERATORS = tuple(term_operators_list)

original__leaf_to_sql = expression.expression._expression__leaf_to_sql


class CustomExpression(expression.expression):
def __leaf_to_sql(self, leaf: tuple, model: BaseModel, alias: str) -> SQL:
"""
The function `__leaf_to_sql` processes a leaf tuple to generate a SQL query for a GeoField in a
BaseModel.
:param leaf: The `leaf` parameter is a tuple containing three elements: the left operand, the
comparison operator, and the right operand
:type leaf: tuple
:param model: The `model` parameter in the given code snippet refers to an instance of a
BaseModel class. It is used to access fields and their properties within the model. The
BaseModel class likely represents a data model or entity in the application, and it contains
information about the fields and their types that are used to
:type model: BaseModel
:param alias: The `alias` parameter in the `__leaf_to_sql` method is a string that represents an
alias for the table in the SQL query. It is used to specify a shorthand name for the table in
the query to make the query more readable and to avoid naming conflicts when joining multiple
tables in a
:type alias: str
:return: The code snippet provided is a method named `__leaf_to_sql` that takes in parameters
`leaf` (a tuple), `model` (an instance of `BaseModel`), and `alias` (a string), and returns an
object of type `SQL`.
"""
if isinstance(leaf, list | tuple):
left, operator, right = leaf
field = model._fields.get(left)
if field and isinstance(field, GeoField):
if operator in GIS_OPERATORS:
operator_obj = Operator(field)
return operator_obj.domain_query(operator, right)

return original__leaf_to_sql(self, leaf, model, alias)


expression.expression._expression__leaf_to_sql = CustomExpression._CustomExpression__leaf_to_sql
50 changes: 23 additions & 27 deletions spp_base_gis/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ def value_to_shape(value, use_wkb=False):
raise TypeError(_("Write/create/search geo type must be wkt/geojson " "string or must respond to wkt"))


def load_geojson(value):
result = json.loads(value)
if not isinstance(result, dict):
raise ValidationError("Value should be a geojson")
return result


def validate_geojson(geojson):
if "coordinates" not in geojson or "type" not in geojson:
raise ValidationError(_("type and coordinates should be in the geojson"))
elif geojson["type"] not in geo_types:
raise ValidationError(_("%(geo_type)s is not a valid type.") % {"geo_type": geojson["type"]})


def validate_shapely_geometry(shapely_geometry):
if shapely_geometry.is_empty:
raise ValidationError(_("Geometry is empty."))


class GeoField(fields.Field):
"""
Base class for geospatial fields, handling common attributes and methods.
Expand All @@ -67,17 +86,10 @@ def __init__(self, *args, **kwargs):

def validate_value(self, value):
try:
result = json.loads(value)
if isinstance(result, dict):
shapely_geometry = shape(result)
if not result.get("coordinates") or not result.get("type"):
raise ValidationError(_("type and coordinates should be in the geojson"))
elif result["type"] not in geo_types:
raise ValidationError(_("%(geo_type)s is not a valid type.") % {"geo_type": result["type"]})
elif not isinstance(shapely_geometry, geo_types[result["type"]]):
raise ValidationError(_("Value must ba a Shapely %(type)s") % {"type": result["type"]})
else:
raise ValidationError("Value should be a geojson")
result = load_geojson(value)
validate_geojson(result)
shapely_geometry = shape(result)
validate_shapely_geometry(shapely_geometry)
except json.JSONDecodeError as e:
raise ValidationError(e) from e

Expand Down Expand Up @@ -178,15 +190,6 @@ def update_db_column(self, model, column_info):
expected_geo_values = (self.srid, self.geo_type.upper(), self.dim)
self.update_geometry_columns(cursor, table_name, column_name, expected_geo_values)

# if column_info["udt_name"] in self.column_cast_from:
# sql.convert_column(cursor, table_name, column_name, self.column_type[1])
# else:
# new_column_name = sql.find_unique_column_name(cursor, table_name, self.name)
# if column_info["is_nullable"] == "NO":
# sql.drop_not_null(cursor, table_name, column_name)
# sql.rename_column(cursor, table_name, column_name, new_column_name)
# sql.create_column(cursor, table_name, column_name, self.column_type[1], self.string)


class GeoPointField(GeoField):
type = "geo_point"
Expand All @@ -206,13 +209,6 @@ class GeoPolygonField(GeoField):
geo_type = Polygon.__name__


# class GeoMultiPolygonField(GeoField):
# type = "geo_multi_polygon"
# geo_class = MultiPolygon
# geo_type = MultiPolygon.__name__


fields.GeoPointField = GeoPointField
fields.GeoLineStringField = GeoLineStringField
fields.GeoPolygonField = GeoPolygonField
# fields.GeoMultiPolygonField = GeoMultiPolygonField
213 changes: 213 additions & 0 deletions spp_base_gis/models/base.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,51 @@
import logging

from shapely.geometry import mapping

from odoo import _, api, models
from odoo.exceptions import MissingError, UserError

from .. import fields as geo_fields
from ..operators import Operator

_logger = logging.getLogger(__name__)

ALLOWED_LAYER_TYPE = list(Operator.ALLOWED_LAYER_TYPE.values())

# Interchange keys and values
RELATION_TO_OPERATION = {value: key for key, value in Operator.OPERATION_TO_RELATION.items()}


def is_valid_coordinates(latitude, longitude):
"""
Checks if the provided latitude and longitude values are valid.
This function checks if the latitude and longitude are of type int or float,
and if they fall within the valid range for geographical coordinates.
The valid range for latitude is -90 to 90 (inclusive), and for longitude is -180 to 180 (inclusive).
Parameters:
latitude (int, float): The latitude value to check. Should be a number between -90 and 90.
longitude (int, float): The longitude value to check. Should be a number between -180 and 180.
Returns:
bool: True if the latitude and longitude are valid, False otherwise.
"""

if not isinstance(latitude, int | float) or not isinstance(longitude, int | float):
return False

# Check latitude
if latitude < -90 or latitude > 90:
return False

# Check longitude
if longitude < -180 or longitude > 180:
return False

# If both checks pass, the coordinates are valid
return True


class Base(models.AbstractModel):
_inherit = "base"
Expand Down Expand Up @@ -93,3 +132,177 @@ def get_edit_info_for_gis_column(self, column):
"default_zoom": view.default_zoom,
"default_center": view.default_center,
}

@api.model
def get_fields_of_type(self, field_type: str | list) -> list:
"""
This Python function retrieves fields of a specified type from a model object.
:param field_type: The `field_type` parameter in the `get_fields_of_type` method can be either a
string or a list of strings. The method filters fields based on their type, which is specified
by the `field_type` parameter. If `field_type` is a string, the method will return a
:type field_type: str | list
:return: The `get_fields_of_type` method returns a list of fields from the model that match the
specified field type or types.
"""

# Get the model
model = self.env[self._name].sudo()

# Filter fields by type
if isinstance(field_type, str):
fields = [field for field in model._fields.values() if field.type == field_type]
elif isinstance(field_type, list):
fields = [field for field in model._fields.values() if field.type in field_type]
else:
raise ValueError(_("Invalid field type: %s") % field_type)

return fields

@api.model
def shape_to_geojson(self, shape):
"""
The function `shape_to_geojson` converts a shape object to a GeoJSON format using the `mapping`
function.
:param shape: The `shape` parameter in the `shape_to_geojson` function is typically a geometric
shape object, such as a Point, LineString, Polygon, etc., from a library like Shapely in Python.
The `mapping` function is used to convert these geometric shapes into GeoJSON format,
:return: The function `shape_to_geojson` is returning the GeoJSON representation of the input
`shape` object by using the `mapping` function.
"""
return mapping(shape)

@api.model
def convert_feature_to_featurecollection(self, features: list | dict) -> dict:
"""
The function `convert_feature_to_featurecollection` converts a list of features into a GeoJSON
FeatureCollection.
:param features: The `features` parameter in the `convert_feature_to_featurecollection` function
is expected to be a list of feature objects. These feature objects typically represent
geographic features and are structured in a specific format, such as GeoJSON format. The
function takes this list of features and wraps them in a GeoJSON
:type features: list
:return: A dictionary is being returned with the keys "type" and "features". The value of the
"type" key is set to "FeatureCollection", and the value of the "features" key is set to the
input parameter `features`, which is a list of features.
"""
return {"type": "FeatureCollection", "features": features if isinstance(features, list) else [features]}

@api.model
def get_field_type_from_layer_type(self, layer_type):
"""
The function `get_field_type_from_layer_type` maps a layer type to a corresponding field type
for geographic data, raising a UserError if the layer type is invalid.
:param layer_type: The `get_field_type_from_layer_type` function takes a `layer_type` as input
and returns the corresponding field type based on a mapping defined in the `layer_type_mapping`
dictionary. The mapping assigns a field type to each layer type - "point", "line", and "polygon"
:return: The function `get_field_type_from_layer_type` returns the corresponding field type
based on the given `layer_type`. If the `layer_type` is "point", it returns "geo_point". If the
`layer_type` is "line", it returns "geo_line". If the `layer_type` is "polygon", it returns
"geo_polygon". If the `layer_type` is not one
"""
layer_type_mapping = {
"point": "geo_point",
"line": "geo_line",
"polygon": "geo_polygon",
}

try:
return layer_type_mapping[layer_type]
except KeyError as e:
raise UserError(_("Invalid layer type %s") % layer_type) from e

@api.model
def gis_locational_query(
self, longitude: float, latitude: float, layer_type="polygon", spatial_relation="intersects", distance=None
):
"""
The function `gis_locational_query` performs a spatial query based on given coordinates, layer
type, spatial relation, and optional distance.
:param longitude: The `longitude` parameter is a float value representing the longitudinal
coordinate of a location
:type longitude: float
:param latitude: Latitude is the angular distance of a location north or south of the earth's
equator, measured in degrees. It ranges from -90 degrees (South Pole) to +90 degrees (North
Pole)
:type latitude: float
:param layer_type: The `layer_type` parameter in the `gis_locational_query` function specifies
the type of layer to query. It can be set to "polygon" or any other allowed layer type. If the
provided `layer_type` is not in the list of allowed layer types, an error will be raised,
defaults to polygon (optional)
:param spatial_relation: The `spatial_relation` parameter in the `gis_locational_query` function
determines the spatial relationship used in the query to filter the results. It specifies how
the provided point (defined by the longitude and latitude) should relate to the geometries in
the spatial layer being queried. The possible values for, defaults to intersects (optional)
:param distance: The `distance` parameter in the `gis_locational_query` function is used to
specify a distance in meters for a spatial query. If provided, the function will search for
features within the specified distance from the given latitude and longitude coordinates. If the
`distance` parameter is not provided (i.e
:return: The function `gis_locational_query` returns a FeatureCollection containing features
that match the specified criteria based on the provided longitude, latitude, layer type, spatial
relation, and optional distance.
"""
if not is_valid_coordinates(latitude, longitude):
raise UserError(_("Invalid coordinates: latitude=%s, longitude=%s") % (latitude, longitude))
if layer_type not in ALLOWED_LAYER_TYPE:
raise UserError(_("Invalid layer type %s") % layer_type)
if spatial_relation not in Operator.POSTGIS_SPATIAL_RELATION.keys():
raise UserError(_("Invalid spatial relation %s") % spatial_relation)
if distance:
if not isinstance(distance, int | float):
raise UserError(_("Distance must be a number"))
if distance <= 0:
raise UserError(_("Distance must be a positive number"))

layer_type = self.get_field_type_from_layer_type(layer_type)

fields = self.get_fields_of_type(layer_type)

features = []

for field in fields:
value_wkt = f"POINT({longitude} {latitude})"
if distance:
value = (value_wkt, distance)
else:
value = value_wkt

domain = [(field.name, RELATION_TO_OPERATION[spatial_relation], value)]
result = self.search(domain)
if result:
features.extend(result.get_feature(field.name))

return self.convert_feature_to_featurecollection(features)

def get_feature(self, field_name):
"""
The function `get_feature` generates a list of features with specified properties for each
record in a dataset.
:param field_name: The `field_name` parameter in the `get_feature` method is used to specify the
name of the field in the record object from which the geometry data will be extracted. This
field name is dynamically retrieved using `getattr(rec, field_name)` within the method to get
the geometry data for each record
:return: The `get_feature` method returns a list of features, where each feature is a dictionary
containing information about a record in the dataset. Each feature has a "type" key with the
value "Feature", a "geometry" key with the geojson representation of the record's shape based on
the specified field name, and a "properties" key with additional information such as the
record's name.
"""
features = []
for rec in self:
if hasattr(rec, field_name) and (geo_shape := getattr(rec, field_name)):
feature = {
"type": "Feature",
"geometry": rec.shape_to_geojson(geo_shape),
"properties": {
"name": rec.name,
# TODO: Add more properties
},
}
features.append(feature)
return features
Loading

0 comments on commit cae6435

Please sign in to comment.