diff --git a/gns3server/compute/project.py b/gns3server/compute/project.py index 713f221c5..3e7b3cad3 100644 --- a/gns3server/compute/project.py +++ b/gns3server/compute/project.py @@ -480,11 +480,13 @@ class Project: stat_info = await wait_run_in_executor(lambda e=entry: e.stat()) is_dir = await wait_run_in_executor(lambda e=entry: e.is_dir()) if is_dir: + created_at: str | None = None + modified_at: str | None = None try: created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError): - created_at = modified_at = "" + created_at = modified_at = None files.append( { "path": rel_path, @@ -506,7 +508,7 @@ class Project: created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError): - created_at = modified_at = "" + created_at = modified_at = None files.append( { "path": rel_path, @@ -537,7 +539,7 @@ class Project: created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError): - created_at = modified_at = "" + created_at = modified_at = None files.append( { "path": os.path.relpath(dir_full_path, base_path), @@ -567,7 +569,7 @@ class Project: modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError) as e: log.warning(f"Invalid timestamp for '{rel_path}': {e}") - created_at = modified_at = "" + created_at = modified_at = None files.append( { "path": rel_path, diff --git a/gns3server/schemas/controller/projects.py b/gns3server/schemas/controller/projects.py index 6354b7696..1b84b5586 100644 --- a/gns3server/schemas/controller/projects.py +++ b/gns3server/schemas/controller/projects.py @@ -15,6 +15,7 @@ # along with this program. If not, see . +from datetime import datetime from enum import Enum from typing import List, Optional from uuid import UUID @@ -111,8 +112,8 @@ class NodeFile(BaseModel): path: str = Field(..., description="File name") size: int = Field(..., description="File size in bytes") - created_at: str = Field(..., description="File creation time (ISO 8601)") - modified_at: str = Field(..., description="File modification time (ISO 8601)") + created_at: Optional[datetime] = Field(None, description="File creation time (ISO 8601)") + modified_at: Optional[datetime] = Field(None, description="File modification time (ISO 8601)") file_type: str = Field(..., description="File type determined by the file command") diff --git a/gns3server/schemas/controller/snapshots.py b/gns3server/schemas/controller/snapshots.py index bf46f2bbe..d86fd21aa 100644 --- a/gns3server/schemas/controller/snapshots.py +++ b/gns3server/schemas/controller/snapshots.py @@ -15,10 +15,11 @@ # along with this program. If not, see . +from datetime import datetime, timezone from typing import Optional from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class SnapshotBase(BaseModel): @@ -44,4 +45,14 @@ class Snapshot(SnapshotBase): name: str = Field(..., description="Name of the snapshot") filename: str = Field(..., description="Filename of the snapshot") description: str = Field(..., description="Description of the snapshot") - created_at: int = Field(..., description="Date of the snapshot (UTC timestamp)") + created_at: int = Field( + ..., description="Date of the snapshot (UTC timestamp). Deprecated: use created", deprecated=True + ) + created: datetime = Field(..., description="Date of the snapshot (ISO 8601, UTC)") + + @model_validator(mode="before") + @classmethod + def _derive_created(cls, data): + if isinstance(data, dict) and "created" not in data and data.get("created_at") is not None: + data = {**data, "created": datetime.fromtimestamp(data["created_at"], tz=timezone.utc)} + return data diff --git a/tests/api/routes/controller/test_snapshots.py b/tests/api/routes/controller/test_snapshots.py index 73f6e1ca7..aeff6d9f9 100644 --- a/tests/api/routes/controller/test_snapshots.py +++ b/tests/api/routes/controller/test_snapshots.py @@ -17,6 +17,7 @@ import os import uuid +from datetime import datetime, timezone import pytest import pytest_asyncio @@ -54,6 +55,9 @@ class TestSnapshotRoutes: response = await client.get(app.url_path_for("get_snapshots", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 1 + data = response.json()[0] + assert isinstance(data["created_at"], int) + assert datetime.fromisoformat(data["created"]) == datetime.fromtimestamp(data["created_at"], tz=timezone.utc) async def test_delete_snapshot( self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot @@ -80,3 +84,15 @@ class TestSnapshotRoutes: response = await client.post(app.url_path_for("create_snapshot", project_id=project.id), json={"name": "snap1"}) assert response.status_code == status.HTTP_201_CREATED assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 2 + data = response.json() + assert isinstance(data["created_at"], int) + assert datetime.fromisoformat(data["created"]) == datetime.fromtimestamp(data["created_at"], tz=timezone.utc) + + async def test_snapshot_schema_timestamps(self, app: FastAPI) -> None: + + schemas = app.openapi()["components"]["schemas"] + assert schemas["Snapshot"]["properties"]["created"]["format"] == "date-time" + assert schemas["Snapshot"]["properties"]["created_at"]["type"] == "integer" + assert schemas["Snapshot"]["properties"]["created_at"]["deprecated"] is True + for field in ("created_at", "modified_at"): + assert {"type": "string", "format": "date-time"} in schemas["NodeFile"]["properties"][field]["anyOf"]