diff --git a/requirements-internal.txt b/requirements-internal.txt new file mode 100644 index 00000000..f4a5dd4b --- /dev/null +++ b/requirements-internal.txt @@ -0,0 +1,54 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --cert=None --client-cert=None --index-url=None --pip-args=None requirements-internal.in +# +blinker==1.9.0 + # via + # flask + # opengeodeweb-microservice +click==8.3.0 + # via + # flask + # opengeodeweb-microservice +fastjsonschema==2.21.1 + # via opengeodeweb-microservice +flask==3.1.2 + # via + # flask-sqlalchemy + # opengeodeweb-microservice +flask-sqlalchemy==3.1.1 + # via opengeodeweb-microservice +greenlet==3.2.4 + # via + # opengeodeweb-microservice + # sqlalchemy +itsdangerous==2.2.0 + # via + # flask + # opengeodeweb-microservice +jinja2==3.1.6 + # via + # flask + # opengeodeweb-microservice +markupsafe==3.0.3 + # via + # flask + # jinja2 + # opengeodeweb-microservice + # werkzeug +opengeodeweb-microservice==1.0.3 + # via -r requirements-internal.in +sqlalchemy==2.0.43 + # via + # flask-sqlalchemy + # opengeodeweb-microservice +typing-extensions==4.15.0 + # via + # opengeodeweb-microservice + # sqlalchemy +werkzeug==3.1.3 + # via + # flask + # opengeodeweb-microservice diff --git a/requirements.in b/requirements.in index 1a55b764..80417d35 100644 --- a/requirements.in +++ b/requirements.in @@ -8,4 +8,4 @@ geode-viewables==3.3.0 flask[async]==3.1.2 flask-cors==6.0.1 werkzeug==3.1.2 -flask-sqlalchemy==3.1.1 \ No newline at end of file +flask-sqlalchemy==3.1.1 diff --git a/requirements.txt b/requirements.txt index 27e6fb92..75960ff4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,7 +59,7 @@ opengeode-io==7.4.0 # -r requirements.in # geode-viewables # opengeode-geosciencesio -sqlalchemy==2.0.43 +sqlalchemy==2.0.44 # via flask-sqlalchemy typing-extensions==4.15.0 # via sqlalchemy @@ -69,4 +69,3 @@ werkzeug==3.1.2 # flask # flask-cors -opengeodeweb-microservice~=1.0 diff --git a/src/opengeodeweb_back/app.py b/src/opengeodeweb_back/app.py index 48381159..c83372cf 100644 --- a/src/opengeodeweb_back/app.py +++ b/src/opengeodeweb_back/app.py @@ -4,30 +4,26 @@ import os import time from typing import Any - import flask import flask_cors # type: ignore from flask import Flask, Response from flask_cors import cross_origin from werkzeug.exceptions import HTTPException - from opengeodeweb_back import utils_functions, app_config from opengeodeweb_back.routes import blueprint_routes from opengeodeweb_back.routes.models import blueprint_models +from opengeodeweb_back.routes.create import blueprint_create from opengeodeweb_microservice.database.connection import init_database - """ Global config """ app: Flask = flask.Flask(__name__) """ Config variables """ FLASK_DEBUG = True if os.environ.get("FLASK_DEBUG", default=None) == "True" else False - if FLASK_DEBUG == False: app.config.from_object(app_config.ProdConfig) else: app.config.from_object(app_config.DevConfig) - DEFAULT_HOST: str = app.config.get("DEFAULT_HOST") or "localhost" DEFAULT_PORT: int = int(app.config.get("DEFAULT_PORT") or 5000) DEFAULT_DATA_FOLDER_PATH: str = app.config.get("DEFAULT_DATA_FOLDER_PATH") or "./data" @@ -38,18 +34,21 @@ app.config.get("SECONDS_BETWEEN_SHUTDOWNS") or 60.0 ) - app.register_blueprint( blueprint_routes.routes, url_prefix="/opengeodeweb_back", name="opengeodeweb_back", ) - app.register_blueprint( blueprint_models.routes, url_prefix="/opengeodeweb_back/models", name="opengeodeweb_models", ) +app.register_blueprint( + blueprint_create.routes, + url_prefix="/opengeodeweb_back/create", + name="opengeodeweb_create", +) if FLASK_DEBUG == False: utils_functions.set_interval( @@ -62,6 +61,11 @@ def errorhandler(e: HTTPException) -> tuple[dict[str, Any], int] | Response: return utils_functions.handle_exception(e) +@app.errorhandler(Exception) +def handle_generic_exception(e: Exception) -> Response: + return flask.make_response({"error": str(e)}, 500) + + @app.route( "/error", methods=["POST"], @@ -126,20 +130,16 @@ def run_server() -> None: help="Number of minutes before the server times out", ) args = parser.parse_args() - app.config.update(DATA_FOLDER_PATH=args.data_folder_path) app.config.update(UPLOAD_FOLDER=args.upload_folder_path) app.config.update(MINUTES_BEFORE_TIMEOUT=args.timeout) - flask_cors.CORS(app, origins=args.allowed_origins) - print( f"Host: {args.host}, Port: {args.port}, Debug: {args.debug}, " f"Data folder path: {args.data_folder_path}, Timeout: {args.timeout}, " f"Origins: {args.allowed_origins}", flush=True, ) - db_filename: str = app.config.get("DATABASE_FILENAME") or "database.db" db_path = os.path.join(args.data_folder_path, db_filename) os.makedirs(os.path.dirname(db_path), exist_ok=True) @@ -147,7 +147,6 @@ def run_server() -> None: app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False init_database(app, db_filename) print(f"Database initialized at: {db_path}", flush=True) - app.run(debug=args.debug, host=args.host, port=args.port, ssl_context=SSL) diff --git a/src/opengeodeweb_back/routes/blueprint_routes.py b/src/opengeodeweb_back/routes/blueprint_routes.py index 7f965009..453ce892 100644 --- a/src/opengeodeweb_back/routes/blueprint_routes.py +++ b/src/opengeodeweb_back/routes/blueprint_routes.py @@ -10,7 +10,8 @@ # Local application imports from .. import geode_functions, utils_functions - +from opengeodeweb_microservice.database.data import Data +from opengeodeweb_microservice.database.connection import get_session from .models import blueprint_models routes = flask.Blueprint("routes", __name__, url_prefix="/opengeodeweb_back") @@ -36,7 +37,6 @@ def teardown_request(exception): name=blueprint_models.routes.name, ) - schemas = os.path.join(os.path.dirname(__file__), "schemas") with open( @@ -258,30 +258,6 @@ def save_viewable_file(): ) -with open(os.path.join(schemas, "create_point.json"), "r") as file: - create_point_json = json.load(file) - - -@routes.route(create_point_json["route"], methods=create_point_json["methods"]) -def create_point(): - utils_functions.validate_request(flask.request, create_point_json) - title = flask.request.get_json()["title"] - x = flask.request.get_json()["x"] - y = flask.request.get_json()["y"] - z = flask.request.get_json()["z"] - class_ = geode_functions.geode_object_class("PointSet3D") - PointSet3D = class_.create() - builder = geode_functions.create_builder("PointSet3D", PointSet3D) - builder.create_point(opengeode.Point3D([x, y, z])) - builder.set_name(title) - return flask.make_response( - utils_functions.generate_native_viewable_and_light_viewable_from_object( - "PointSet3D", PointSet3D - ), - 200, - ) - - with open(os.path.join(schemas, "texture_coordinates.json"), "r") as file: texture_coordinates_json = json.load(file) diff --git a/src/opengeodeweb_back/routes/create/blueprint_create.py b/src/opengeodeweb_back/routes/create/blueprint_create.py new file mode 100644 index 00000000..db7bcb24 --- /dev/null +++ b/src/opengeodeweb_back/routes/create/blueprint_create.py @@ -0,0 +1,122 @@ +# Standard library imports +import json +import os +from typing import Any, TypedDict + +# Third party imports +import flask +import opengeode + +# Local application imports +from opengeodeweb_back import geode_functions, utils_functions +from opengeodeweb_back.utils_functions import save_all_viewables_and_return_info + +routes = flask.Blueprint("create", __name__, url_prefix="/opengeodeweb_back/create") +schemas = os.path.join(os.path.dirname(__file__), "schemas") + +# --- Type definitions --- +type SchemaDict = dict[str, Any] + + +class PointDict(TypedDict): + x: float + y: float + + +class CreatePointParams(TypedDict): + name: str + x: float + y: float + z: float + + +class CreateAOIParams(TypedDict): + name: str + points: list[PointDict] + z: float + + +# Load schemas +with open(os.path.join(schemas, "create_point.json"), "r") as file: + create_point_json: SchemaDict = json.load(file) + + +@routes.route(create_point_json["route"], methods=create_point_json["methods"]) +def create_point() -> flask.Response: + """Endpoint to create a single point in 3D space.""" + print(f"create_point : {flask.request=}", flush=True) + utils_functions.validate_request(flask.request, create_point_json) + + # Extract and validate data from request + params: CreatePointParams = flask.request.get_json() + name = params["name"] + x = params["x"] + y = params["y"] + z = params["z"] + + # Create the point + class_ = geode_functions.geode_object_class("PointSet3D") + pointset = class_.create() + builder = geode_functions.create_builder("PointSet3D", pointset) + builder.set_name(name) + builder.create_point(opengeode.Point3D([x, y, z])) + + # Save and get info + result = save_all_viewables_and_return_info( + geode_object="PointSet3D", + data=pointset, + ) + result["name"] = name + if "binary_light_viewable" not in result: + raise ValueError("binary_light_viewable is missing in the result") + return flask.make_response(result, 200) + + +# Load schema for AOI creation +with open(os.path.join(schemas, "create_aoi.json"), "r") as file: + create_aoi_json: SchemaDict = json.load(file) + + +@routes.route(create_aoi_json["route"], methods=create_aoi_json["methods"]) +def create_aoi() -> flask.Response: + """Endpoint to create an Area of Interest (AOI) as an EdgedCurve3D.""" + print(f"create_aoi : {flask.request=}", flush=True) + utils_functions.validate_request(flask.request, create_aoi_json) + + # Extract and validate data from request + params: CreateAOIParams = flask.request.get_json() + name = params["name"] + points = params["points"] + z = params["z"] + + # Create the edged curve + class_ = geode_functions.geode_object_class("EdgedCurve3D") + edged_curve = class_.create() + builder = geode_functions.create_builder("EdgedCurve3D", edged_curve) + builder.set_name(name) + + # Create vertices first + vertex_indices: list[int] = [] + for point in points: + vertex_id = builder.create_point(opengeode.Point3D([point["x"], point["y"], z])) + vertex_indices.append(vertex_id) + + # Create edges between consecutive vertices and close the loop + num_vertices = len(vertex_indices) + for i in range(num_vertices): + next_i = (i + 1) % num_vertices + edge_id = builder.create_edge() + builder.set_edge_vertex(opengeode.EdgeVertex(edge_id, 0), vertex_indices[i]) + builder.set_edge_vertex( + opengeode.EdgeVertex(edge_id, 1), vertex_indices[next_i] + ) + + # Save and get info + result = save_all_viewables_and_return_info( + geode_object="EdgedCurve3D", + data=edged_curve, + ) + result["name"] = name + if "binary_light_viewable" not in result: + raise ValueError("binary_light_viewable is missing in the result") + return flask.make_response(result, 200) diff --git a/src/opengeodeweb_back/routes/create/schemas/create_aoi.json b/src/opengeodeweb_back/routes/create/schemas/create_aoi.json new file mode 100644 index 00000000..0dc7a3f4 --- /dev/null +++ b/src/opengeodeweb_back/routes/create/schemas/create_aoi.json @@ -0,0 +1,46 @@ +{ + "route": "/create_aoi", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the AOI" + }, + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + }, + "minItems": 4, + "maxItems": 4 + }, + "z": { + "type": "number" + }, + "id": { + "type": "string" + } + }, + "required": [ + "name", + "points", + "z" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/src/opengeodeweb_back/routes/create/schemas/create_point.json b/src/opengeodeweb_back/routes/create/schemas/create_point.json new file mode 100644 index 00000000..3f0da7dc --- /dev/null +++ b/src/opengeodeweb_back/routes/create/schemas/create_point.json @@ -0,0 +1,29 @@ +{ + "route": "/create_point", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "name", + "x", + "y", + "z" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/src/opengeodeweb_back/routes/schemas/create_point.json b/src/opengeodeweb_back/routes/schemas/create_point.json deleted file mode 100644 index 9e204def..00000000 --- a/src/opengeodeweb_back/routes/schemas/create_point.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "route": "/create_point", - "methods": [ - "POST" - ], - "type": "object", - "properties": { - "title": { - "type": "string", - "minLength": 1 - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "z": { - "type": "number" - } - }, - "required": [ - "title", - "x", - "y", - "z" - ], - "additionalProperties": false -} \ No newline at end of file diff --git a/src/opengeodeweb_back/utils_functions.py b/src/opengeodeweb_back/utils_functions.py index 92633d03..40826c6c 100644 --- a/src/opengeodeweb_back/utils_functions.py +++ b/src/opengeodeweb_back/utils_functions.py @@ -163,7 +163,7 @@ def create_data_folder_from_id(data_id: str) -> str: def save_all_viewables_and_return_info( geode_object: str, data: Any, - input_file: str, + input_file: str | None = None, additional_files: list[str] | None = None, ) -> dict[str, Any]: if additional_files is None: diff --git a/tests/conftest.py b/tests/conftest.py index 04a50e03..25cc679f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ import pytest # Local application imports -from opengeodeweb_back.app import app +from src.opengeodeweb_back.app import app # from opengeodeweb_back import app_config from opengeodeweb_microservice.database.connection import init_database diff --git a/tests/test_create_routes.py b/tests/test_create_routes.py new file mode 100644 index 00000000..ec6f6d8a --- /dev/null +++ b/tests/test_create_routes.py @@ -0,0 +1,210 @@ +# Standard library imports +import os +import uuid +from typing import Any, Callable, Dict, List + +# Third party imports +import pytest +from flask.testing import FlaskClient + +# Local application imports +from src.opengeodeweb_back import test_utils + + +@pytest.fixture +def point_data() -> Dict[str, Any]: + return {"name": "test_point", "x": 1.0, "y": 2.0, "z": 3.0} + + +@pytest.fixture +def aoi_data() -> Dict[str, Any]: + return { + "name": "test_aoi", + "points": [ + {"x": 0.0, "y": 0.0}, + {"x": 1.0, "y": 0.0}, + {"x": 1.0, "y": 1.0}, + {"x": 0.0, "y": 1.0}, + ], + "z": 0.0, + } + + +def test_create_point(client: FlaskClient, point_data: Dict[str, Any]) -> None: + """Test the creation of a point with valid data.""" + route: str = "/opengeodeweb_back/create/create_point" + + # Test with all required data + response = client.post(route, json=point_data) + assert response.status_code == 200 + + # Verify response data + response_data: Any = response.json + assert "viewable_file_name" in response_data + assert "id" in response_data + assert "name" in response_data + assert "native_file_name" in response_data + assert "object_type" in response_data + assert "geode_object" in response_data + + assert response_data["name"] == point_data["name"] + assert response_data["object_type"] == "mesh" + assert response_data["geode_object"] == "PointSet3D" + + # Test with missing parameters + test_utils.test_route_wrong_params(client, route, lambda: point_data.copy()) # type: ignore + + +def test_create_aoi(client: FlaskClient, aoi_data: Dict[str, Any]) -> None: + """Test the creation of an AOI with valid data.""" + route: str = "/opengeodeweb_back/create/create_aoi" + + # Test with all required data + response = client.post(route, json=aoi_data) + assert response.status_code == 200 + + # Verify response data + response_data: Any = response.json + assert "viewable_file_name" in response_data + assert "id" in response_data + assert "name" in response_data + assert "native_file_name" in response_data + assert "object_type" in response_data + assert "geode_object" in response_data + + assert response_data["name"] == aoi_data["name"] + assert response_data["object_type"] == "mesh" + assert response_data["geode_object"] == "EdgedCurve3D" + + # Test with missing parameters + test_utils.test_route_wrong_params(client, route, lambda: aoi_data.copy()) # type: ignore + + +def test_create_point_with_invalid_data(client: FlaskClient) -> None: + """Test the point creation endpoint with invalid data.""" + route: str = "/opengeodeweb_back/create/create_point" + + # Test with non-numeric coordinates + invalid_data: Dict[str, Any] = { + "name": "invalid_point", + "x": "not_a_number", + "y": 2.0, + "z": 3.0, + } + response = client.post(route, json=invalid_data) + assert response.status_code == 400 + + # Test with missing coordinates + invalid_data = {"name": "invalid_point", "y": 2.0, "z": 3.0} + response = client.post(route, json=invalid_data) + assert response.status_code == 400 + + +def test_create_aoi_with_invalid_data( + client: FlaskClient, aoi_data: Dict[str, Any] +) -> None: + """Test the AOI creation endpoint with invalid data.""" + route: str = "/opengeodeweb_back/create/create_aoi" + + # Test with invalid points + invalid_data: Dict[str, Any] = { + **aoi_data, + "points": [ + {"x": "not_a_number", "y": 0.0}, + {"x": 1.0, "y": 0.0}, + {"x": 1.0, "y": 1.0}, + {"x": 0.0, "y": 1.0}, + ], + } + response = client.post(route, json=invalid_data) + assert response.status_code == 400 + + # Test with too few points + invalid_data = {**aoi_data, "points": [{"x": 0.0, "y": 0.0}, {"x": 1.0, "y": 0.0}]} + response = client.post(route, json=invalid_data) + assert response.status_code == 400 + + # Test with invalid z value + invalid_data = {**aoi_data, "z": "not_a_number"} + response = client.post(route, json=invalid_data) + assert response.status_code == 400 + + +def test_create_point_file_generation( + client: FlaskClient, point_data: Dict[str, Any] +) -> None: + """Test that the point creation generates the correct files.""" + route: str = "/opengeodeweb_back/create/create_point" + + # Make the request + response = client.post(route, json=point_data) + assert response.status_code == 200 + response_data: Any = response.json + + # Get the data folder path for this specific ID + DATA_FOLDER_PATH: str = client.application.config["DATA_FOLDER_PATH"] + data_id: str = response_data["id"] + data_folder: str = os.path.join(DATA_FOLDER_PATH, data_id) + + # Check that the data folder exists + assert os.path.exists(data_folder) + assert os.path.isdir(data_folder) + + # Check native file exists + native_file_path: str = os.path.join(data_folder, response_data["native_file_name"]) + assert os.path.exists(native_file_path) + + # Check viewable file exists + viewable_file_path: str = os.path.join( + data_folder, response_data["viewable_file_name"] + ) + assert os.path.exists(viewable_file_path) + + # Check light viewable file exists if present + if "binary_light_viewable" in response_data: + light_viewable_file_path: str = os.path.join(data_folder, "light_viewable.vtp") + assert os.path.exists(light_viewable_file_path) + + # Verify file extensions + assert response_data["native_file_name"].endswith(".og_pts3d") + assert response_data["viewable_file_name"].endswith(".vtp") + + +def test_create_aoi_file_generation( + client: FlaskClient, aoi_data: Dict[str, Any] +) -> None: + """Test that the AOI creation generates the correct files.""" + route: str = "/opengeodeweb_back/create/create_aoi" + + # Make the request + response = client.post(route, json=aoi_data) + assert response.status_code == 200 + response_data: Any = response.json + + # Get the data folder path for this specific ID + DATA_FOLDER_PATH: str = client.application.config["DATA_FOLDER_PATH"] + data_id: str = response_data["id"] + data_folder: str = os.path.join(DATA_FOLDER_PATH, data_id) + + # Check that the data folder exists + assert os.path.exists(data_folder) + assert os.path.isdir(data_folder) + + # Check native file exists + native_file_path: str = os.path.join(data_folder, response_data["native_file_name"]) + assert os.path.exists(native_file_path) + + # Check viewable file exists + viewable_file_path: str = os.path.join( + data_folder, response_data["viewable_file_name"] + ) + assert os.path.exists(viewable_file_path) + + # Check light viewable file exists if present + if "binary_light_viewable" in response_data: + light_viewable_file_path: str = os.path.join(data_folder, "light_viewable.vtp") + assert os.path.exists(light_viewable_file_path) + + # Verify file extensions + assert response_data["native_file_name"].endswith(".og_edc3d") + assert response_data["viewable_file_name"].endswith(".vtp") diff --git a/tests/test_routes.py b/tests/test_routes.py index fd1095e2..3d47d597 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -258,22 +258,6 @@ def test_polyhedron_attribute_names(client, test_id): assert type(polyhedron_attribute_name) is str -def test_create_point(client): - route = f"/opengeodeweb_back/create_point" - get_full_data = lambda: {"title": "test_point", "x": 1, "y": 2, "z": 3} - - # Normal test with all keys - response = client.post(route, json=get_full_data()) - assert response.status_code == 200 - viewable_file_name = response.json["viewable_file_name"] - assert type(viewable_file_name) is str - id = response.json.get("id") - assert type(id) is str - - # Test all params - test_utils.test_route_wrong_params(client, route, get_full_data) - - def test_database_uri_path(client): app = client.application with app.app_context():