Skip to content
Open
Show file tree
Hide file tree
Changes from 17 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
32 changes: 32 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,27 @@
],
"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"
],
"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
104 changes: 104 additions & 0 deletions src/opengeodeweb_back/routes/blueprint_routes.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
# Standard library imports
from posixpath import relpath
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 +272,102 @@ 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)

if not params.filename:
flask.abort(400, "filename is required")
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 *
20 changes: 20 additions & 0 deletions src/opengeodeweb_back/routes/schemas/export_project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"route": "/export_project",
"methods": [
"POST"
],
"type": "object",
"properties": {
"snapshot": {
"type": "object"
},
"filename": {
"type": "string",
"minLength": 1
}
},
"required": [
"snapshot"
],
"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, Optional


@dataclass
class ExportProject(DataClassJsonMixin):
snapshot: Dict[str, Any]
filename: Optional[str] = None
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
66 changes: 66 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,67 @@ 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
48 changes: 48 additions & 0 deletions tests/test_utils_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import flask
import shutil
import uuid
import zipfile
import io

# Local application imports
from opengeodeweb_microservice.database.data import Data
Expand Down Expand Up @@ -206,3 +208,49 @@ def test_generate_native_viewable_and_light_viewable_from_file(client):
assert isinstance(result["object_type"], str)
assert isinstance(result["binary_light_viewable"], str)
assert isinstance(result["input_file"], str)


def test_send_file_multiple_returns_zip(client, tmp_path):
app = client.application
with app.app_context():
app.config["UPLOAD_FOLDER"] = str(tmp_path)
file_paths = []
for i, content in [(1, b"hello 1"), (2, b"hello 2")]:
file_path = tmp_path / f"tmp_send_file_{i}.txt"
file_path.write_bytes(content)
file_paths.append(str(file_path))
with app.test_request_context():
response = utils_functions.send_file(
app.config["UPLOAD_FOLDER"], file_paths, "bundle"
)
assert response.status_code == 200
assert response.mimetype == "application/zip"
new_file_name = response.headers.get("new-file-name")
assert new_file_name == "bundle.zip"
zip_path = os.path.join(app.config["UPLOAD_FOLDER"], new_file_name)
with zipfile.ZipFile(zip_path, "r") as zip_file:
zip_entries = zip_file.namelist()
assert "tmp_send_file_1.txt" in zip_entries
assert "tmp_send_file_2.txt" in zip_entries
response.close()


def test_send_file_single_returns_octet_binary(client, tmp_path):
app = client.application
with app.app_context():
app.config["UPLOAD_FOLDER"] = str(tmp_path)
file_path = tmp_path / "tmp_send_file_1.txt"
file_path.write_bytes(b"hello 1")
with app.test_request_context():
response = utils_functions.send_file(
app.config["UPLOAD_FOLDER"], [str(file_path)], "tmp_send_file_1.txt"
)
assert response.status_code == 200
assert response.mimetype == "application/octet-binary"
new_file_name = response.headers.get("new-file-name")
assert new_file_name == "tmp_send_file_1.txt"
zip_path = os.path.join(app.config["UPLOAD_FOLDER"], new_file_name)
with open(zip_path, "rb") as f:
file_bytes = f.read()
assert file_bytes == b"hello 1"
response.close()