diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index 939f3206..50408514 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -1,46 +1,9 @@ { "opengeodeweb_back": { "create": { - "create_voi": { - "$id": "opengeodeweb_back/create/create_voi", - "route": "/create_voi", - "methods": [ - "POST" - ], - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the VOI" - }, - "aoi_id": { - "type": "string", - "description": "ID of the corresponding AOI" - }, - "z_min": { - "type": "number", - "description": "Minimum Z coordinate" - }, - "z_max": { - "type": "number", - "description": "Maximum Z coordinate" - }, - "id": { - "type": "string", - "description": "Optional ID for updating existing VOI" - } - }, - "required": [ - "name", - "aoi_id", - "z_min", - "z_max" - ], - "additionalProperties": false - }, - "create_point": { - "$id": "opengeodeweb_back/create/create_point", - "route": "/create_point", + "point": { + "$id": "opengeodeweb_back/create/point", + "route": "/point", "methods": [ "POST" ], @@ -67,52 +30,6 @@ "z" ], "additionalProperties": false - }, - "create_aoi": { - "$id": "opengeodeweb_back/create/create_aoi", - "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": 3 - }, - "z": { - "type": "number" - }, - "id": { - "type": "string" - } - }, - "required": [ - "name", - "points", - "z" - ], - "additionalProperties": false } }, "models": { @@ -343,6 +260,17 @@ "required": [], "additionalProperties": false }, + "import_extension": { + "$id": "opengeodeweb_back/import_extension", + "route": "/import_extension", + "methods": [ + "POST" + ], + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, "geographic_coordinate_systems": { "$id": "opengeodeweb_back/geographic_coordinate_systems", "route": "/geographic_coordinate_systems", diff --git a/pyproject.toml b/pyproject.toml index c1add323..a721bab0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ "Bug Tracker" = "https://github.com/Geode-solutions/OpenGeodeWeb-Back/issues" [project.scripts] -opengeodeweb-back = "opengeodeweb_back.app:run_server" +opengeodeweb-back = "opengeodeweb_back.app:run_opengeodeweb_back" [tool.setuptools.dynamic] dependencies = { file = ["requirements.txt"] } diff --git a/requirements.txt b/requirements.txt index 5697c0a5..08d64bdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,4 +60,3 @@ werkzeug==3.1.2 # flask # flask-cors -opengeodeweb-microservice==1.*,>=1.0.12 diff --git a/src/opengeodeweb_back/app.py b/src/opengeodeweb_back/app.py index bf167398..432cc4f3 100644 --- a/src/opengeodeweb_back/app.py +++ b/src/opengeodeweb_back/app.py @@ -14,108 +14,112 @@ from opengeodeweb_back.routes.create import blueprint_create from opengeodeweb_microservice.database import connection -""" 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" -ORIGINS: Any = app.config.get("ORIGINS") -TIMEOUT: int = int(app.config.get("MINUTES_BEFORE_TIMEOUT") or 30) -SSL: Any = app.config.get("SSL") -SECONDS_BETWEEN_SHUTDOWNS: float = float( - app.config.get("SECONDS_BETWEEN_SHUTDOWNS") or 60.0 -) - - -@app.before_request -def before_request() -> flask.Response | None: - if flask.request.method == "OPTIONS": - response = flask.make_response() - response.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,DELETE,OPTIONS" - return response - utils_functions.before_request(flask.current_app) - return None - - -@app.teardown_request -def teardown_request(exception: BaseException | None) -> None: - utils_functions.teardown_request(flask.current_app, exception) - - -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( - utils_functions.kill_task, SECONDS_BETWEEN_SHUTDOWNS, app - ) - - -@app.errorhandler(HTTPException) -def errorhandler(exception: HTTPException) -> tuple[dict[str, Any], int] | Response: - return utils_functions.handle_exception(exception) - - -@app.errorhandler(Exception) -def handle_generic_exception(exception: Exception) -> Response: - print("\033[91mError:\033[0m \033[91m" + str(exception) + "\033[0m", flush=True) - return flask.make_response({"description": str(exception)}, 500) - - -@app.route( - "/error", - methods=["POST"], -) -def return_error() -> Response: - flask.abort(500, f"Test") - return flask.make_response({}, 500) - - -@app.route("/", methods=["POST"]) -@cross_origin() -def root() -> Response: - return flask.make_response({}, 200) - - -@app.route("/kill", methods=["POST"]) -@cross_origin() -def kill() -> None: - print("Manual server kill, shutting down...", flush=True) - os._exit(0) - - -def run_server() -> None: + +def create_app(name: str) -> flask.Flask: + app = 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) + + if FLASK_DEBUG == False: + SECONDS_BETWEEN_SHUTDOWNS: float = float( + app.config.get("SECONDS_BETWEEN_SHUTDOWNS") or 60.0 + ) + utils_functions.set_interval( + utils_functions.kill_task, SECONDS_BETWEEN_SHUTDOWNS, app + ) + + @app.before_request + def before_request() -> flask.Response | None: + if flask.request.method == "OPTIONS": + response = flask.make_response() + response.headers["Access-Control-Allow-Methods"] = ( + "GET,POST,PUT,DELETE,OPTIONS" + ) + return response + utils_functions.before_request(flask.current_app) + return None + + @app.teardown_request + def teardown_request(exception: BaseException | None) -> None: + utils_functions.teardown_request(flask.current_app, exception) + + @app.errorhandler(HTTPException) + def errorhandler(exception: HTTPException) -> tuple[dict[str, Any], int] | Response: + return utils_functions.handle_exception(exception) + + @app.errorhandler(Exception) + def handle_generic_exception(exception: Exception) -> Response: + print("\033[91mError:\033[0m \033[91m" + str(exception) + "\033[0m", flush=True) + return flask.make_response({"description": str(exception)}, 500) + + @app.route( + "/error", + methods=["POST"], + ) + def return_error() -> Response: + flask.abort(500, f"Test") + return flask.make_response({}, 500) + + @app.route("/", methods=["POST"]) + @cross_origin() + def root() -> Response: + return flask.make_response({}, 200) + + @app.route("/kill", methods=["POST"]) + @cross_origin() + def kill() -> None: + print("Manual server kill, shutting down...", flush=True) + os._exit(0) + + return app + + +def register_ogw_back_blueprints(app: flask.Flask) -> None: + 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", + ) + + +def run_server(app: Flask) -> None: parser = argparse.ArgumentParser( prog="OpenGeodeWeb-Back", description="Backend server for OpenGeodeWeb" ) - parser.add_argument("--host", type=str, default=DEFAULT_HOST, help="Host to run on") parser.add_argument( - "-p", "--port", type=int, default=DEFAULT_PORT, help="Port to listen on" + "--host", + type=str, + default=app.config.get("DEFAULT_HOST"), + help="Host to run on", + ) + parser.add_argument( + "-p", + "--port", + type=int, + default=app.config.get("DEFAULT_PORT"), + help="Port to listen on", ) parser.add_argument( "-d", "--debug", - default=FLASK_DEBUG, + default=app.config.get("FLASK_DEBUG"), help="Whether to run in debug mode", action="store_true", ) @@ -123,30 +127,33 @@ def run_server() -> None: "-dfp", "--data_folder_path", type=str, - default=DEFAULT_DATA_FOLDER_PATH, + default=app.config.get("DEFAULT_DATA_FOLDER_PATH"), help="Path to the folder where data is stored", ) parser.add_argument( "-ufp", "--upload_folder_path", type=str, - default=DEFAULT_DATA_FOLDER_PATH, + default=app.config.get("DEFAULT_DATA_FOLDER_PATH"), help="Path to the folder where uploads are stored", ) parser.add_argument( "-origins", "--allowed_origins", - default=ORIGINS, + default=app.config.get("ORIGINS"), help="Origins that are allowed to connect to the server", ) parser.add_argument( "-t", "--timeout", - default=TIMEOUT, + default=app.config.get("MINUTES_BEFORE_TIMEOUT"), 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( + EXTENSIONS_FOLDER_PATH=os.path.join(str(args.data_folder_path), "extensions") + ) 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) @@ -158,16 +165,28 @@ def run_server() -> None: ) db_filename: str = app.config.get("DATABASE_FILENAME") or "project.db" - db_path = os.path.join(args.data_folder_path, db_filename) + db_path = os.path.join(str(args.data_folder_path), db_filename) os.makedirs(os.path.dirname(db_path), exist_ok=True) app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False connection.init_database(db_path) print(f"Database initialized at: {db_path}", flush=True) - app.run(debug=args.debug, host=args.host, port=args.port, ssl_context=SSL) + app.run( + debug=args.debug, + host=args.host, + port=args.port, + ssl_context=app.config.get("SSL"), + ) + + +def run_opengeodeweb_back() -> None: + app = create_app(__name__) + register_ogw_back_blueprints(app) + run_server(app) + print("Server stopped", flush=True) # ''' Main ''' if __name__ == "__main__": - run_server() + run_opengeodeweb_back() diff --git a/src/opengeodeweb_back/app_config.py b/src/opengeodeweb_back/app_config.py index 095b2754..2b17c4dc 100644 --- a/src/opengeodeweb_back/app_config.py +++ b/src/opengeodeweb_back/app_config.py @@ -24,6 +24,7 @@ class ProdConfig(Config): MINUTES_BEFORE_TIMEOUT = "1" SECONDS_BETWEEN_SHUTDOWNS = "10" DATA_FOLDER_PATH = "/data" + EXTENSIONS_FOLDER_PATH = os.path.join(DATA_FOLDER_PATH, "extensions") class DevConfig(Config): @@ -33,3 +34,4 @@ class DevConfig(Config): SECONDS_BETWEEN_SHUTDOWNS = "10" BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_FOLDER_PATH = os.path.join(BASE_DIR, "data") + EXTENSIONS_FOLDER_PATH = os.path.join(DATA_FOLDER_PATH, "extensions") diff --git a/src/opengeodeweb_back/routes/blueprint_routes.py b/src/opengeodeweb_back/routes/blueprint_routes.py index 81e1c4cf..9ee41869 100644 --- a/src/opengeodeweb_back/routes/blueprint_routes.py +++ b/src/opengeodeweb_back/routes/blueprint_routes.py @@ -17,9 +17,9 @@ from opengeodeweb_microservice.database import connection # Local application imports -from .models import blueprint_models -from . import schemas from opengeodeweb_back import geode_functions, utils_functions +from opengeodeweb_back.routes import schemas +from opengeodeweb_back.routes.models import blueprint_models from opengeodeweb_back.geode_objects import geode_objects from opengeodeweb_back.geode_objects.types import geode_object_type from opengeodeweb_back.geode_objects.geode_mesh import GeodeMesh @@ -506,3 +506,73 @@ def import_project() -> flask.Response: except KeyError: snapshot = {} return flask.make_response({"snapshot": snapshot}, 200) + + +@routes.route( + schemas_dict["import_extension"]["route"], + methods=schemas_dict["import_extension"]["methods"], +) +def import_extension() -> flask.Response: + """Import a .vext extension file and extract its contents.""" + utils_functions.validate_request(flask.request, schemas_dict["import_extension"]) + + if "file" not in flask.request.files: + flask.abort(400, "No .vext file provided under 'file'") + + vext_file = flask.request.files["file"] + assert vext_file.filename is not None + filename = werkzeug.utils.secure_filename(os.path.basename(vext_file.filename)) + + if not filename.lower().endswith(".vext"): + flask.abort(400, "Uploaded file must be a .vext") + + # Create extensions directory in the data folder + extensions_folder = flask.current_app.config["EXTENSIONS_FOLDER_PATH"] + os.makedirs(extensions_folder, exist_ok=True) + + extension_name = ( + filename.rsplit("-", 1)[0] if "-" in filename else filename.replace(".vext", "") + ) + extension_path = os.path.join(extensions_folder, extension_name) + + # Remove existing extension if present + if os.path.exists(extension_path): + shutil.rmtree(extension_path) + + os.makedirs(extension_path, exist_ok=True) + + # Extract the .vext file + vext_file.stream.seek(0) + with zipfile.ZipFile(vext_file.stream) as zip_archive: + zip_archive.extractall(extension_path) + + # Look for the backend executable and frontend JS + backend_executable = None + frontend_file = None + + for file in os.listdir(extension_path): + file_path = os.path.join(extension_path, file) + if os.path.isfile(file_path): + if file.endswith(".es.js"): + frontend_file = file_path + elif not file.endswith(".js") and not file.endswith(".css"): + backend_executable = file_path + os.chmod(backend_executable, 0o755) + + if not frontend_file: + flask.abort(400, "Invalid .vext file: missing frontend JavaScript") + if not backend_executable: + flask.abort(400, "Invalid .vext file: missing backend executable") + + assert frontend_file is not None + with open(frontend_file, "r", encoding="utf-8") as f: + frontend_content = f.read() + + return flask.make_response( + { + "extension_name": extension_name, + "frontend_content": frontend_content, + "backend_path": backend_executable, + }, + 200, + ) diff --git a/src/opengeodeweb_back/routes/create/blueprint_create.py b/src/opengeodeweb_back/routes/create/blueprint_create.py index b788f261..0eab0ca6 100644 --- a/src/opengeodeweb_back/routes/create/blueprint_create.py +++ b/src/opengeodeweb_back/routes/create/blueprint_create.py @@ -8,7 +8,7 @@ # Local application imports from opengeodeweb_back import geode_functions, utils_functions -from . import schemas +import opengeodeweb_back.routes.create.schemas as schemas from opengeodeweb_back.geode_objects.geode_point_set3d import GeodePointSet3D from opengeodeweb_back.geode_objects.geode_edged_curve3d import GeodeEdgedCurve3D @@ -17,15 +17,13 @@ @routes.route( - schemas_dict["create_point"]["route"], - methods=schemas_dict["create_point"]["methods"], + schemas_dict["point"]["route"], + methods=schemas_dict["point"]["methods"], ) -def create_point() -> flask.Response: +def point() -> flask.Response: """Endpoint to create a single point in 3D space.""" - json_data = utils_functions.validate_request( - flask.request, schemas_dict["create_point"] - ) - params = schemas.CreatePoint.from_dict(json_data) + json_data = utils_functions.validate_request(flask.request, schemas_dict["point"]) + params = schemas.Point.from_dict(json_data) # Create the point pointset = GeodePointSet3D() @@ -38,84 +36,3 @@ def create_point() -> flask.Response: pointset ) return flask.make_response(result, 200) - - -@routes.route( - schemas_dict["create_aoi"]["route"], methods=schemas_dict["create_aoi"]["methods"] -) -def create_aoi() -> flask.Response: - """Endpoint to create an Area of Interest (AOI) as an EdgedCurve3D.""" - json_data = utils_functions.validate_request( - flask.request, schemas_dict["create_aoi"] - ) - params = schemas.CreateAoi.from_dict(json_data) - - # Create the edged curve - edged_curve = GeodeEdgedCurve3D() - builder = edged_curve.builder() - builder.set_name(params.name) - - # Create vertices first - for point in params.points: - builder.create_point(opengeode.Point3D([point.x, point.y, params.z])) - - # Create edges between consecutive vertices and close the loop - num_vertices = len(params.points) - for i in range(num_vertices): - next_i = (i + 1) % num_vertices - builder.create_edge_with_vertices(i, next_i) - - # Save and get info - result = utils_functions.generate_native_viewable_and_light_viewable_from_object( - edged_curve - ) - return flask.make_response(result, 200) - - -@routes.route( - schemas_dict["create_voi"]["route"], methods=schemas_dict["create_voi"]["methods"] -) -def create_voi() -> flask.Response: - """Endpoint to create a Volume of Interest (VOI) as an EdgedCurve3D (a bounding box/prism).""" - json_data = utils_functions.validate_request( - flask.request, schemas_dict["create_voi"] - ) - params = schemas.CreateVoi.from_dict(json_data) - - aoi_data = geode_functions.get_data_info(params.aoi_id) - if not aoi_data: - flask.abort(404, f"AOI with id {params.aoi_id} not found") - - aoi_object = geode_functions.load_geode_object(params.aoi_id) - if not isinstance(aoi_object, GeodeEdgedCurve3D): - flask.abort(400, f"AOI with id {params.aoi_id} is not a GeodeEdgedCurve3D") - - aoi_curve = aoi_object.edged_curve - nb_points = aoi_curve.nb_vertices() - - edged_curve = GeodeEdgedCurve3D() - builder = edged_curve.builder() - builder.set_name(params.name) - - for point_id in range(nb_points): - aoi_point = aoi_curve.point(point_id) - builder.create_point( - opengeode.Point3D([aoi_point.value(0), aoi_point.value(1), params.z_min]) - ) - - for point_id in range(nb_points): - aoi_point = aoi_curve.point(point_id) - builder.create_point( - opengeode.Point3D([aoi_point.value(0), aoi_point.value(1), params.z_max]) - ) - - for point_id in range(nb_points): - next_point = (point_id + 1) % nb_points - builder.create_edge_with_vertices(point_id, next_point) - builder.create_edge_with_vertices(point_id + nb_points, next_point + nb_points) - builder.create_edge_with_vertices(point_id, point_id + nb_points) - - result = utils_functions.generate_native_viewable_and_light_viewable_from_object( - edged_curve - ) - return flask.make_response(result, 200) diff --git a/src/opengeodeweb_back/routes/create/schemas/__init__.py b/src/opengeodeweb_back/routes/create/schemas/__init__.py index 3d3f9b73..86ba70b7 100644 --- a/src/opengeodeweb_back/routes/create/schemas/__init__.py +++ b/src/opengeodeweb_back/routes/create/schemas/__init__.py @@ -1,3 +1 @@ -from .create_voi import * -from .create_point import * -from .create_aoi import * +from .point import * diff --git a/src/opengeodeweb_back/routes/create/schemas/create_aoi.json b/src/opengeodeweb_back/routes/create/schemas/create_aoi.json deleted file mode 100644 index c9321491..00000000 --- a/src/opengeodeweb_back/routes/create/schemas/create_aoi.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "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": 3 - }, - "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_aoi.py b/src/opengeodeweb_back/routes/create/schemas/create_aoi.py deleted file mode 100644 index 38f8f53a..00000000 --- a/src/opengeodeweb_back/routes/create/schemas/create_aoi.py +++ /dev/null @@ -1,25 +0,0 @@ -from dataclasses_json import DataClassJsonMixin -from dataclasses import dataclass -from typing import List, Optional - - -@dataclass -class Point(DataClassJsonMixin): - def __post_init__(self) -> None: - print(self, flush=True) - - x: float - y: float - - -@dataclass -class CreateAoi(DataClassJsonMixin): - def __post_init__(self) -> None: - print(self, flush=True) - - name: str - """Name of the AOI""" - - points: List[Point] - z: float - id: Optional[str] = None diff --git a/src/opengeodeweb_back/routes/create/schemas/create_point.json b/src/opengeodeweb_back/routes/create/schemas/create_point.json deleted file mode 100644 index 3f0da7dc..00000000 --- a/src/opengeodeweb_back/routes/create/schemas/create_point.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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/create/schemas/create_voi.json b/src/opengeodeweb_back/routes/create/schemas/create_voi.json deleted file mode 100644 index 1b3d630d..00000000 --- a/src/opengeodeweb_back/routes/create/schemas/create_voi.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "route": "/create_voi", - "methods": [ - "POST" - ], - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the VOI" - }, - "aoi_id": { - "type": "string", - "description": "ID of the corresponding AOI" - }, - "z_min": { - "type": "number", - "description": "Minimum Z coordinate" - }, - "z_max": { - "type": "number", - "description": "Maximum Z coordinate" - }, - "id": { - "type": "string", - "description": "Optional ID for updating existing VOI" - } - }, - "required": [ - "name", - "aoi_id", - "z_min", - "z_max" - ], - "additionalProperties": false -} \ No newline at end of file diff --git a/src/opengeodeweb_back/routes/create/schemas/create_voi.py b/src/opengeodeweb_back/routes/create/schemas/create_voi.py deleted file mode 100644 index 885d4dc7..00000000 --- a/src/opengeodeweb_back/routes/create/schemas/create_voi.py +++ /dev/null @@ -1,24 +0,0 @@ -from dataclasses_json import DataClassJsonMixin -from dataclasses import dataclass -from typing import Optional - - -@dataclass -class CreateVoi(DataClassJsonMixin): - def __post_init__(self) -> None: - print(self, flush=True) - - aoi_id: str - """ID of the corresponding AOI""" - - name: str - """Name of the VOI""" - - z_max: float - """Maximum Z coordinate""" - - z_min: float - """Minimum Z coordinate""" - - id: Optional[str] = None - """Optional ID for updating existing VOI""" diff --git a/src/opengeodeweb_back/routes/create/schemas/point.json b/src/opengeodeweb_back/routes/create/schemas/point.json new file mode 100644 index 00000000..07480ee3 --- /dev/null +++ b/src/opengeodeweb_back/routes/create/schemas/point.json @@ -0,0 +1,22 @@ +{ + "route": "/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 +} diff --git a/src/opengeodeweb_back/routes/create/schemas/create_point.py b/src/opengeodeweb_back/routes/create/schemas/point.py similarity index 84% rename from src/opengeodeweb_back/routes/create/schemas/create_point.py rename to src/opengeodeweb_back/routes/create/schemas/point.py index e59577f6..d8b90216 100644 --- a/src/opengeodeweb_back/routes/create/schemas/create_point.py +++ b/src/opengeodeweb_back/routes/create/schemas/point.py @@ -3,7 +3,7 @@ @dataclass -class CreatePoint(DataClassJsonMixin): +class Point(DataClassJsonMixin): def __post_init__(self) -> None: print(self, flush=True) diff --git a/src/opengeodeweb_back/routes/schemas/__init__.py b/src/opengeodeweb_back/routes/schemas/__init__.py index 4ad76b82..5d37ca18 100644 --- a/src/opengeodeweb_back/routes/schemas/__init__.py +++ b/src/opengeodeweb_back/routes/schemas/__init__.py @@ -9,6 +9,7 @@ from .kill import * from .inspect_file import * from .import_project import * +from .import_extension import * from .geographic_coordinate_systems import * from .geode_objects_and_output_extensions import * from .export_project import * diff --git a/src/opengeodeweb_back/routes/schemas/import_extension.json b/src/opengeodeweb_back/routes/schemas/import_extension.json new file mode 100644 index 00000000..4d80f553 --- /dev/null +++ b/src/opengeodeweb_back/routes/schemas/import_extension.json @@ -0,0 +1,10 @@ +{ + "route": "/import_extension", + "methods": [ + "POST" + ], + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false +} diff --git a/src/opengeodeweb_back/routes/schemas/import_extension.py b/src/opengeodeweb_back/routes/schemas/import_extension.py new file mode 100644 index 00000000..256cb5fa --- /dev/null +++ b/src/opengeodeweb_back/routes/schemas/import_extension.py @@ -0,0 +1,10 @@ +from dataclasses_json import DataClassJsonMixin +from dataclasses import dataclass + + +@dataclass +class ImportExtension(DataClassJsonMixin): + def __post_init__(self) -> None: + print(self, flush=True) + + pass diff --git a/tests/conftest.py b/tests/conftest.py index f9815593..be69c56c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,12 +11,14 @@ import pytest # Local application imports -from opengeodeweb_back.app import app +from opengeodeweb_back.app import create_app, register_ogw_back_blueprints from opengeodeweb_microservice.database.connection import init_database TEST_ID = "1" +app = create_app(__name__) + @pytest.fixture(scope="session", autouse=True) def configure_test_environment() -> Generator[None, None, None]: @@ -39,7 +41,7 @@ def configure_test_environment() -> Generator[None, None, None]: init_database(db_path) os.environ["TEST_DB_PATH"] = str(db_path) - + register_ogw_back_blueprints(app) yield tmp_data_path = app.config.get("DATA_FOLDER_PATH") diff --git a/tests/test_create_routes.py b/tests/test_create_routes.py index 61b1eb42..a90266a9 100644 --- a/tests/test_create_routes.py +++ b/tests/test_create_routes.py @@ -14,35 +14,9 @@ def point_data() -> test_utils.JsonData: return {"name": "test_point", "x": 1.0, "y": 2.0, "z": 3.0} -@pytest.fixture -def aoi_data() -> test_utils.JsonData: - 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, - } - - -@pytest.fixture -def voi_data() -> test_utils.JsonData: - """Fixture for Volume of Interest (VOI) test data.""" - return { - "name": "test_voi", - "aoi_id": str(uuid.uuid4()), - "z_min": -50.0, - "z_max": 100.0, - "id": str(uuid.uuid4()), - } - - def test_create_point(client: FlaskClient, point_data: test_utils.JsonData) -> None: """Test the creation of a point with valid data.""" - route: str = "/opengeodeweb_back/create/create_point" + route: str = "/opengeodeweb_back/create/point" # Test with all required data response = client.post(route, json=point_data) @@ -65,57 +39,9 @@ def test_create_point(client: FlaskClient, point_data: test_utils.JsonData) -> N test_utils.test_route_wrong_params(client, route, lambda: point_data.copy()) -def test_create_aoi(client: FlaskClient, aoi_data: test_utils.JsonData) -> 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 = response.get_json() - assert "viewable_file" in response_data - assert "id" in response_data - assert "name" in response_data - assert "native_file" in response_data - assert "viewer_type" in response_data - assert "geode_object_type" in response_data - - assert response_data["name"] == aoi_data["name"] - assert response_data["viewer_type"] == "mesh" - assert response_data["geode_object_type"] == "EdgedCurve3D" - - # Test with missing parameters - test_utils.test_route_wrong_params(client, route, lambda: aoi_data.copy()) - - -def test_create_voi( - client: FlaskClient, aoi_data: test_utils.JsonData, voi_data: test_utils.JsonData -) -> None: - """Test the creation of a VOI with valid data (including optional id).""" - aoi_route = "/opengeodeweb_back/create/create_aoi" - aoi_response = client.post(aoi_route, json=aoi_data) - assert aoi_response.status_code == 200 - aoi_id = aoi_response.get_json()["id"] - - voi_data["aoi_id"] = aoi_id - - voi_route = "/opengeodeweb_back/create/create_voi" - response = client.post(voi_route, json=voi_data) - assert response.status_code == 200 - - response_data = response.get_json() - assert "id" in response_data - assert "name" in response_data - assert response_data["name"] == voi_data["name"] - assert response_data["viewer_type"] == "mesh" - assert response_data["geode_object_type"] == "EdgedCurve3D" - - 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" + route: str = "/opengeodeweb_back/create/point" # Test with non-numeric coordinates invalid_data: test_utils.JsonData = { @@ -131,54 +57,3 @@ def test_create_point_with_invalid_data(client: FlaskClient) -> None: 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: test_utils.JsonData -) -> None: - """Test the AOI creation endpoint with invalid data.""" - route: str = "/opengeodeweb_back/create/create_aoi" - - invalid_data: test_utils.JsonData = { - **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 - - 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 - - invalid_data = {**aoi_data, "z": "not_a_number"} - response = client.post(route, json=invalid_data) - assert response.status_code == 400 - - -def test_create_voi_with_invalid_data( - client: FlaskClient, aoi_data: test_utils.JsonData, voi_data: test_utils.JsonData -) -> None: - """Test the VOI creation endpoint with invalid data.""" - aoi_route = "/opengeodeweb_back/create/create_aoi" - aoi_response = client.post(aoi_route, json=aoi_data) - assert aoi_response.status_code == 200 - aoi_id = aoi_response.get_json()["id"] - - route = "/opengeodeweb_back/create/create_aoi" - - invalid_data = {**voi_data, "aoi_id": aoi_id, "z_min": "not_a_number"} - response = client.post(route, json=invalid_data) - assert response.status_code == 400 - - invalid_data = {**voi_data, "aoi_id": aoi_id, "z_max": "not_a_number"} - response = client.post(route, json=invalid_data) - assert response.status_code == 400 - - invalid_data = {**voi_data, "aoi_id": 12345} - response = client.post(route, json=invalid_data) - assert response.status_code == 400 diff --git a/tests/test_models_routes.py b/tests/test_models_routes.py index c31ae710..0b272202 100644 --- a/tests/test_models_routes.py +++ b/tests/test_models_routes.py @@ -206,22 +206,102 @@ def test_save_viewable_workflow_from_file(client: FlaskClient) -> None: def test_save_viewable_workflow_from_object(client: FlaskClient) -> None: - route = "/opengeodeweb_back/create/create_aoi" - aoi_data = { - "name": "workflow_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}, - ], + route = "/opengeodeweb_back/create/point" + point_data = { + "name": "workflow_point_3d", + "x": 0.0, + "y": 0.0, "z": 0.0, } - response = client.post(route, json=aoi_data) + response = client.post(route, json=point_data) assert response.status_code == 200 data_id = response.get_json()["id"] assert isinstance(data_id, str) and len(data_id) > 0 - assert response.get_json()["geode_object_type"] == "EdgedCurve3D" + assert response.get_json()["geode_object_type"] == "PointSet3D" assert response.get_json()["viewable_file"].endswith(".vtp") + + +def test_import_extension_route(client: FlaskClient, tmp_path: Path) -> None: + """Test importing a .vext extension file.""" + route = "/opengeodeweb_back/import_extension" + original_data_folder = client.application.config["DATA_FOLDER_PATH"] + new_data_folder = os.path.join(str(tmp_path), "extension_test_data") + client.application.config["DATA_FOLDER_PATH"] = new_data_folder + client.application.config["EXTENSIONS_FOLDER_PATH"] = os.path.join( + new_data_folder, "extensions" + ) + vext_path = tmp_path / "test-extension-1.0.0.vext" + with zipfile.ZipFile(vext_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf: + zipf.writestr( + "test-extension-extension.es.js", + "export const metadata = { id: 'test-extension', name: 'Test Extension' };", + ) + zipf.writestr("test-extension-back", "#!/bin/bash\necho 'mock backend'") + zipf.writestr("test-extension.css", ".test { color: red; }") + with open(vext_path, "rb") as f: + response = client.post( + route, + data={"file": (f, "test-extension-1.0.0.vext")}, + content_type="multipart/form-data", + ) + assert response.status_code == 200 + json_data = response.get_json() + assert "extension_name" in json_data + assert "frontend_content" in json_data + assert "backend_path" in json_data + assert json_data["extension_name"] == "test-extension" + extensions_folder = os.path.join( + client.application.config["DATA_FOLDER_PATH"], "extensions" + ) + extension_path = os.path.join(extensions_folder, "test-extension") + assert os.path.exists(extension_path) + + # Verify frontend content is returned + frontend_content = json_data["frontend_content"] + assert isinstance(frontend_content, str) + assert len(frontend_content) > 0 + assert "export const metadata" in frontend_content + + backend_exec = json_data["backend_path"] + assert os.path.exists(backend_exec) + assert os.access(backend_exec, os.X_OK) + client.application.config["DATA_FOLDER_PATH"] = original_data_folder + + +def test_import_extension_invalid_file(client: FlaskClient, tmp_path: Path) -> None: + """Test importing an invalid .vext file (missing dist folder).""" + route = "/opengeodeweb_back/import_extension" + original_data_folder = client.application.config["DATA_FOLDER_PATH"] + new_data_folder = os.path.join(str(tmp_path), "extension_invalid_test") + client.application.config["DATA_FOLDER_PATH"] = new_data_folder + client.application.config["EXTENSIONS_FOLDER_PATH"] = os.path.join( + new_data_folder, "extensions" + ) + vext_path = tmp_path / "invalid-extension.vext" + with zipfile.ZipFile(vext_path, "w") as zipf: + zipf.writestr("README.md", "This is invalid") + with open(vext_path, "rb") as f: + response = client.post( + route, + data={"file": (f, "invalid-extension.vext")}, + content_type="multipart/form-data", + ) + assert response.status_code == 400 + client.application.config["DATA_FOLDER_PATH"] = original_data_folder + + +def test_import_extension_wrong_extension(client: FlaskClient, tmp_path: Path) -> None: + """Test uploading a file with wrong extension.""" + route = "/opengeodeweb_back/import_extension" + wrong_file = tmp_path / "not-an-extension.zip" + with open(wrong_file, "wb") as f: + f.write(b"test content") + with open(wrong_file, "rb") as f: + response = client.post( + route, + data={"file": (f, "not-an-extension.zip")}, + content_type="multipart/form-data", + ) + assert response.status_code == 400