Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ac85cf4
feat(export_and_import_routes): add export, import route and schemas …
MaxNumerique Oct 29, 2025
875590c
import route and schema
MaxNumerique Oct 29, 2025
86c0dcc
Apply prepare changes
MaxNumerique Oct 29, 2025
1ffd4ef
zip_path
MaxNumerique Oct 29, 2025
c6ad376
Merge branch 'feat/save_and_load' of https://github.com/Geode-solutio…
MaxNumerique Oct 29, 2025
e90175a
ask database for data entries to generate the zip file
MaxNumerique Oct 30, 2025
035802b
Apply prepare changes
MaxNumerique Oct 30, 2025
3840fc3
tests updated
MaxNumerique Oct 30, 2025
328f17a
Merge branch 'feat/save_and_load' of https://github.com/Geode-solutio…
MaxNumerique Oct 30, 2025
9901c9a
Apply prepare changes
MaxNumerique Oct 30, 2025
ce99d0a
test
MaxNumerique Oct 30, 2025
457f16f
Merge branch 'feat/save_and_load' of https://github.com/Geode-solutio…
MaxNumerique Oct 30, 2025
38a82bf
Apply prepare changes
MaxNumerique Oct 30, 2025
4240888
validate_request
MaxNumerique Oct 30, 2025
8569821
Merge branch 'feat/save_and_load' of https://github.com/Geode-solutio…
MaxNumerique Oct 30, 2025
ef772ee
fix
MaxNumerique Oct 30, 2025
14c6bf6
Apply prepare changes
MaxNumerique Oct 30, 2025
b2cf2e8
test
MaxNumerique Oct 31, 2025
2309b2c
Merge branch 'feat/save_and_load' of https://github.com/Geode-solutio…
MaxNumerique Oct 31, 2025
76aa2b0
Apply prepare changes
MaxNumerique Oct 31, 2025
2266b97
test
MaxNumerique Oct 31, 2025
c35dd51
Merge branch 'feat/save_and_load' of https://github.com/Geode-solutio…
MaxNumerique Oct 31, 2025
65bc037
Apply prepare changes
MaxNumerique Oct 31, 2025
44b879d
test
MaxNumerique Oct 31, 2025
9fd5023
Merge branch 'feat/save_and_load' of https://github.com/Geode-solutio…
MaxNumerique Oct 31, 2025
c306246
removed Any
MaxNumerique Nov 3, 2025
85af818
Apply prepare changes
MaxNumerique Nov 3, 2025
ef26ea5
generate viewables and session.
MaxNumerique Nov 3, 2025
bde6708
Apply prepare changes
MaxNumerique Nov 3, 2025
7afe531
update tests and import_project
MaxNumerique Nov 3, 2025
38ed9d7
Merge branch 'feat/save_and_load' of https://github.com/Geode-solutio…
MaxNumerique Nov 3, 2025
7d98acc
Apply prepare changes
MaxNumerique Nov 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions opengeodeweb_back_schemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,17 @@
],
"additionalProperties": false
},
"import_project": {
"$id": "opengeodeweb_back/import_project",
"route": "/import_project",
"methods": [
"POST"
],
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"geographic_coordinate_systems": {
"$id": "opengeodeweb_back/geographic_coordinate_systems",
"route": "/geographic_coordinate_systems",
Expand Down Expand Up @@ -337,6 +348,28 @@
],
"additionalProperties": false
},
"export_project": {
"$id": "opengeodeweb_back/export_project",
"route": "/export_project",
"methods": [
"POST"
],
"type": "object",
"properties": {
"snapshot": {
"type": "object"
},
"filename": {
"type": "string",
"minLength": 1
}
},
"required": [
"snapshot",
"filename"
],
"additionalProperties": false
},
"allowed_objects": {
"$id": "opengeodeweb_back/allowed_objects",
"route": "/allowed_objects",
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,3 @@ werkzeug==3.1.2
# flask
# flask-cors

opengeodeweb-microservice==1.*,>=1.0.6rc1
101 changes: 101 additions & 0 deletions src/opengeodeweb_back/routes/blueprint_routes.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
# Standard library imports
import os
import time
import shutil

# Third party imports
import flask
import werkzeug
import zipfile
from opengeodeweb_microservice.schemas import get_schemas_dict

# Local application imports
from opengeodeweb_back import geode_functions, utils_functions
from .models import blueprint_models
from . import schemas
from opengeodeweb_microservice.database.data import Data
from opengeodeweb_microservice.database.connection import get_session

routes = flask.Blueprint("routes", __name__, url_prefix="/opengeodeweb_back")

Expand Down Expand Up @@ -267,3 +271,100 @@ def kill() -> flask.Response:
print("Manual server kill, shutting down...", flush=True)
os._exit(0)
return flask.make_response({"message": "Flask server is dead"}, 200)


@routes.route(
schemas_dict["export_project"]["route"],
methods=schemas_dict["export_project"]["methods"],
)
def export_project() -> flask.Response:
utils_functions.validate_request(flask.request, schemas_dict["export_project"])
params = schemas.ExportProject.from_dict(flask.request.get_json())

project_folder: str = flask.current_app.config["DATA_FOLDER_PATH"]
os.makedirs(project_folder, exist_ok=True)

filename: str = werkzeug.utils.secure_filename(os.path.basename(params.filename))
export_zip_path = os.path.join(project_folder, filename)

with get_session() as session:
rows = session.query(Data.id, Data.input_file, Data.additional_files).all()

with zipfile.ZipFile(
export_zip_path, "w", compression=zipfile.ZIP_DEFLATED
) as zip_file:
database_root_path = os.path.join(project_folder, "project.db")
if os.path.isfile(database_root_path):
zip_file.write(database_root_path, "project.db")

for data_id, input_file, additional_files in rows:
base_dir = os.path.join(project_folder, data_id)

input_path = os.path.join(base_dir, str(input_file))
if os.path.isfile(input_path):
zip_file.write(input_path, os.path.join(data_id, str(input_file)))

for relative_path in (
additional_files if isinstance(additional_files, list) else []
):
additional_path = os.path.join(base_dir, relative_path)
if os.path.isfile(additional_path):
zip_file.write(
additional_path, os.path.join(data_id, relative_path)
)

zip_file.writestr("snapshot.json", flask.json.dumps(params.snapshot))

return utils_functions.send_file(project_folder, [export_zip_path], filename)


@routes.route(
schemas_dict["import_project"]["route"],
methods=schemas_dict["import_project"]["methods"],
)
def import_project() -> flask.Response:
if flask.request.method == "OPTIONS":
return flask.make_response({}, 200)
utils_functions.validate_request(flask.request, schemas_dict["import_project"])
if "file" not in flask.request.files:
flask.abort(400, "No zip file provided under 'file'")

zip_file = flask.request.files["file"]
assert zip_file.filename is not None
filename = werkzeug.utils.secure_filename(os.path.basename(zip_file.filename))
if not filename.lower().endswith(".zip"):
flask.abort(400, "Uploaded file must be a .zip")

data_folder_path: str = flask.current_app.config["DATA_FOLDER_PATH"]
try:
if os.path.exists(data_folder_path):
shutil.rmtree(data_folder_path)
os.makedirs(data_folder_path, exist_ok=True)
except PermissionError:
flask.abort(423, "Project files are locked; cannot overwrite")

zip_file.stream.seek(0)
with zipfile.ZipFile(zip_file.stream) as zip_archive:
project_folder = os.path.abspath(data_folder_path)
for member in zip_archive.namelist():
target = os.path.abspath(
os.path.normpath(os.path.join(project_folder, member))
)
if not (
target == project_folder or target.startswith(project_folder + os.sep)
):
flask.abort(400, "Zip contains unsafe paths")
zip_archive.extractall(project_folder)

database_root_path = os.path.join(project_folder, "project.db")
if not os.path.isfile(database_root_path):
flask.abort(400, "Missing project.db at project root")

snapshot = {}
try:
raw = zip_archive.read("snapshot.json").decode("utf-8")
snapshot = flask.json.loads(raw)
except KeyError:
snapshot = {}

return flask.make_response({"snapshot": snapshot}, 200)
2 changes: 2 additions & 0 deletions src/opengeodeweb_back/routes/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from .missing_files import *
from .kill import *
from .inspect_file import *
from .import_project import *
from .geographic_coordinate_systems import *
from .geode_objects_and_output_extensions import *
from .export_project import *
from .allowed_objects import *
from .allowed_files import *
21 changes: 21 additions & 0 deletions src/opengeodeweb_back/routes/schemas/export_project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"route": "/export_project",
"methods": [
"POST"
],
"type": "object",
"properties": {
"snapshot": {
"type": "object"
},
"filename": {
"type": "string",
"minLength": 1
}
},
"required": [
"snapshot",
"filename"
],
"additionalProperties": false
}
9 changes: 9 additions & 0 deletions src/opengeodeweb_back/routes/schemas/export_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from dataclasses_json import DataClassJsonMixin
from dataclasses import dataclass
from typing import Dict, Any


@dataclass
class ExportProject(DataClassJsonMixin):
filename: str
snapshot: Dict[str, Any]
10 changes: 10 additions & 0 deletions src/opengeodeweb_back/routes/schemas/import_project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"route": "/import_project",
"methods": [
"POST"
],
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}
7 changes: 7 additions & 0 deletions src/opengeodeweb_back/routes/schemas/import_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from dataclasses_json import DataClassJsonMixin
from dataclasses import dataclass


@dataclass
class ImportProject(DataClassJsonMixin):
pass
6 changes: 4 additions & 2 deletions src/opengeodeweb_back/utils_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,17 @@ def send_file(
else:
mimetype = "application/zip"
new_file_name = os.path.splitext(new_file_name)[0] + ".zip"
with zipfile.ZipFile(os.path.join(upload_folder, new_file_name), "w") as zipObj:
with zipfile.ZipFile(
os.path.join(os.path.abspath(upload_folder), new_file_name), "w"
) as zipObj:
for saved_file_path in saved_files:
zipObj.write(
saved_file_path,
os.path.basename(saved_file_path),
)

response = flask.send_from_directory(
directory=upload_folder,
directory=os.path.abspath(upload_folder),
path=new_file_name,
as_attachment=True,
mimetype=mimetype,
Expand Down
115 changes: 115 additions & 0 deletions tests/test_models_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from opengeodeweb_back import geode_functions
from opengeodeweb_microservice.database.data import Data
from opengeodeweb_microservice.database.connection import get_session
import zipfile
import json


def test_model_mesh_components(client, test_id):
Expand Down Expand Up @@ -55,3 +57,116 @@ def test_extract_brep_uuids(client, test_id):
assert "uuid_dict" in response.json
uuid_dict = response.json["uuid_dict"]
assert isinstance(uuid_dict, dict)


def test_export_project_route(client, tmp_path):
route = "/opengeodeweb_back/export_project"
snapshot = {
"styles": {"1": {"visibility": True, "opacity": 1.0, "color": [0.2, 0.6, 0.9]}}
}
filename = "export_project_test.zip"
project_folder = client.application.config["DATA_FOLDER_PATH"]
os.makedirs(project_folder, exist_ok=True)
database_root_path = os.path.join(project_folder, "project.db")
with open(database_root_path, "wb") as f:
f.write(b"test_project_db")
response = client.post(route, json={"snapshot": snapshot, "filename": filename})
assert response.status_code == 200
assert response.headers.get("new-file-name") == filename
assert response.mimetype == "application/octet-binary"
response.direct_passthrough = False
zip_bytes = response.get_data()
tmp_zip_path = tmp_path / filename
tmp_zip_path.write_bytes(zip_bytes)
with zipfile.ZipFile(tmp_zip_path, "r") as zip_file:
names = zip_file.namelist()
assert "snapshot.json" in names
parsed = json.loads(zip_file.read("snapshot.json").decode("utf-8"))
assert parsed == snapshot
assert "project.db" in names
response.close()
export_path = os.path.join(project_folder, filename)
if os.path.exists(export_path):
os.remove(export_path)


def test_import_project_route(client, tmp_path):
route = "/opengeodeweb_back/import_project"
snapshot = {
"styles": {"1": {"visibility": True, "opacity": 1.0, "color": [0.2, 0.6, 0.9]}}
}

client.application.config["DATA_FOLDER_PATH"] = os.path.join(
str(tmp_path), "project_data"
)
data_folder = client.application.config["DATA_FOLDER_PATH"]
pre_existing_db_path = os.path.join(data_folder, "project.db")

tmp_zip = tmp_path / "import_project_test.zip"
new_database_bytes = b"new_db_content"
with zipfile.ZipFile(tmp_zip, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
zip_file.writestr("snapshot.json", json.dumps(snapshot))
zip_file.writestr("project.db", new_database_bytes)

with open(tmp_zip, "rb") as file:
response = client.post(
route,
data={"file": (file, "import_project_test.zip")},
content_type="multipart/form-data",
)

assert response.status_code == 200
assert response.json.get("snapshot") == snapshot

assert os.path.exists(pre_existing_db_path)
with open(pre_existing_db_path, "rb") as file:
assert file.read() == new_database_bytes


def test_save_viewable_workflow_from_file(client):
route = "/opengeodeweb_back/save_viewable_file"
payload = {"input_geode_object": "BRep", "filename": "cube.og_brep"}

response = client.post(route, json=payload)
assert response.status_code == 200

data_id = response.json["id"]
assert isinstance(data_id, str) and len(data_id) > 0
assert response.json["viewable_file_name"].endswith(".vtm")

comp_resp = client.post(
"/opengeodeweb_back/models/vtm_component_indices", json={"id": data_id}
)
assert comp_resp.status_code == 200

refreshed = Data.get(data_id)
assert refreshed is not None


def test_save_viewable_workflow_from_object(client):
# Chemin “from object” : passe par un endpoint de création qui génère/sauvegarde via save_viewable.
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},
],
"z": 0.0,
}

response = client.post(route, json=aoi_data)
assert response.status_code == 200

data_id = response.json["id"]
assert isinstance(data_id, str) and len(data_id) > 0
assert response.json["geode_object"] == "EdgedCurve3D"
assert response.json["viewable_file_name"].endswith(".vtp")

attr_resp = client.post(
"/opengeodeweb_back/vertex_attribute_names", json={"id": data_id}
)
assert attr_resp.status_code == 200
assert isinstance(attr_resp.json.get("vertex_attribute_names", []), list)
Loading