diff --git a/app/api/api_v1/endpoints/projects.py b/app/api/api_v1/endpoints/projects.py index 5193eecb..2cad0ea3 100644 --- a/app/api/api_v1/endpoints/projects.py +++ b/app/api/api_v1/endpoints/projects.py @@ -13,10 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import json from http import HTTPStatus from typing import List, Sequence, Union from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from pydantic import ValidationError, parse_obj_as from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified @@ -328,3 +332,74 @@ def __persist_pics_update(db: Session, project: Project) -> Project: db.commit() db.refresh(project) return project + + +@router.get("/{id}/export", response_model=schemas.ProjectCreate) +def export_project_config( + *, + db: Session = Depends(get_db), + id: int, +) -> JSONResponse: + """ + Exports the project config by id. + + Args: + id (int): project id + + Raises: + HTTPException: if no project exists for provided project id + + Returns: + JSONResponse: json representation of the project with the informed project id + """ + # Retrieve project by project_id using schemas.ProjectCreate schema + project = schemas.ProjectCreate(**__project(db=db, id=id).__dict__) + + options: dict = {"media_type": "application/json"} + filename = f"{project.name}-project-config.json" + options["headers"] = {"Content-Disposition": f'attachment; filename="{filename}"'} + + return JSONResponse( + jsonable_encoder(project), + **options, + ) + + +@router.post("/import", response_model=schemas.Project) +def importproject_config( + *, + db: Session = Depends(get_db), + import_file: UploadFile = File(...), +) -> models.Project: + """ + Imports the project config + + Args: + import_file : The project config file to be imported + + Raises: + ValidationError: if the imported project config contains invalid information + + Returns: + Project: newly created project record + """ + + file_content = import_file.file.read().decode("utf-8") + file_dict = json.loads(file_content) + + try: + imported_project: schemas.ProjectCreate = parse_obj_as( + schemas.ProjectCreate, file_dict + ) + except ValidationError as error: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail=str(error) + ) + + try: + return crud.project.create(db=db, obj_in=imported_project) + except TestEnvironmentConfigError as e: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail=str(e), + ) diff --git a/app/tests/api/api_v1/test_projects.py b/app/tests/api/api_v1/test_projects.py index 0bdf054b..82c4c98c 100644 --- a/app/tests/api/api_v1/test_projects.py +++ b/app/tests/api/api_v1/test_projects.py @@ -15,7 +15,9 @@ # # flake8: noqa # Ignore flake8 check for this file +import json from http import HTTPStatus +from io import BytesIO from pathlib import Path from typing import Any @@ -24,10 +26,9 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session -from app import crud +from app import crud, models, schemas from app.core.config import settings from app.default_environment_config import default_environment_config -from app.models.project import Project from app.tests.utils.project import ( create_random_project, create_random_project_archived, @@ -65,6 +66,49 @@ }, } +project_json_data = { + "name": "New Project IMPORTED", + "config": { + "test_parameters": None, + "network": { + "wifi": {"ssid": "testharness", "password": "wifi-password"}, + "thread": { + "rcp_serial_path": "/dev/ttyACM0", + "rcp_baudrate": 115200, + "on_mesh_prefix": "fd11:22::/64", + "network_interface": "eth0", + "dataset": { + "channel": "15", + "panid": "0x1234", + "extpanid": "1111111122222222", + "networkkey": "00112233445566778899aabbccddeeff", + "networkname": "DEMO", + }, + "otbr_docker_image": None, + }, + }, + "dut_config": { + "discriminator": "3840", + "setup_code": "20202021", + "pairing_mode": "onnetwork", + "chip_timeout": None, + "chip_use_paa_certs": False, + "trace_log": True, + }, + }, + "pics": { + "clusters": { + "Access Control cluster": { + "name": "Test PICS", + "items": { + "ACL.S": {"number": "PICS.S", "enabled": False}, + "ACL.C": {"number": "PICS.C", "enabled": True}, + }, + } + } + }, +} + def test_create_project_default_config(client: TestClient) -> None: data: dict[str, Any] = {"name": "Foo"} @@ -151,7 +195,7 @@ def test_read_project(client: TestClient, db: Session) -> None: def test_read_multiple_project(client: TestClient, db: Session) -> None: project1 = create_random_project(db, config={}) project2 = create_random_project(db, config={}) - limit = db.scalar(select(func.count(Project.id))) or 0 + limit = db.scalar(select(func.count(models.Project.id))) or 0 response = client.get( f"{settings.API_V1_STR}/projects?limit={limit}", ) @@ -164,7 +208,7 @@ def test_read_multiple_project(client: TestClient, db: Session) -> None: def test_read_multiple_project_by_archived(client: TestClient, db: Session) -> None: archived = create_random_project_archived(db, config={}) - limit = db.scalar(select(func.count(Project.id))) or 0 + limit = db.scalar(select(func.count(models.Project.id))) or 0 response = client.get( f"{settings.API_V1_STR}/projects?limit={limit}", @@ -371,3 +415,39 @@ def test_applicable_test_cases_empty_pics(client: TestClient, db: Session) -> No # the project is created with empty pics # expected value: applicable_test_cases == 0 assert len(content["test_cases"]) == 0 + + +def test_export_project(client: TestClient, db: Session) -> None: + project = create_random_project_with_pics(db=db, config={}) + project_create_schema = schemas.ProjectCreate(**project.__dict__) + # retrieve the project config + response = client.get( + f"{settings.API_V1_STR}/projects/{project.id}/export", + ) + + validate_json_response( + response=response, + expected_status_code=HTTPStatus.OK, + expected_content=jsonable_encoder(project_create_schema), + ) + + +def test_import_project(client: TestClient, db: Session) -> None: + imported_file_content = json.dumps(project_json_data).encode("utf-8") + data = BytesIO(imported_file_content) + + files = { + "import_file": ( + "project.json", + data, + "multipart/form-data", + ) + } + + response = client.post(f"{settings.API_V1_STR}/projects/import", files=files) + + validate_json_response( + response=response, + expected_status_code=HTTPStatus.OK, + expected_content=project_json_data, + )