diff --git a/server/storage/s3/__init__.py b/server/storage/s3/__init__.py index ef37a51..f38a5f8 100644 --- a/server/storage/s3/__init__.py +++ b/server/storage/s3/__init__.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from typing import Optional from lib.logging_utils import init_logger from server.lib.vcon_redis import VconRedis @@ -35,9 +36,23 @@ def _create_s3_client(opts: dict): return boto3.client("s3", **client_kwargs) -def _build_s3_key(vcon_uuid: str, s3_path: Optional[str] = None) -> str: - """Build the S3 object key for a vCon.""" - key = f"{vcon_uuid}.vcon" +def _build_s3_key(vcon_uuid: str, created_at: Optional[str] = None, s3_path: Optional[str] = None) -> str: + """Build the S3 object key for a vCon with date-based folder structure. + + Args: + vcon_uuid: The vCon UUID + created_at: ISO format timestamp from the vCon's created_at field. + If provided, creates date-based folder structure (YYYY/MM/DD). + s3_path: Optional prefix path in the S3 bucket + + Returns: + S3 key in format: [s3_path/][YYYY/MM/DD/]uuid.vcon + """ + if created_at: + timestamp = datetime.fromisoformat(created_at).strftime("%Y/%m/%d") + key = f"{timestamp}/{vcon_uuid}.vcon" + else: + key = f"{vcon_uuid}.vcon" if not s3_path: return key return f"{s3_path.rstrip('/')}/{key}" @@ -53,7 +68,7 @@ def save( vcon = vcon_redis.get_vcon(vcon_uuid) s3 = _create_s3_client(opts) - destination_directory = _build_s3_key(vcon_uuid, opts.get("s3_path")) + destination_directory = _build_s3_key(vcon_uuid, vcon.created_at, opts.get("s3_path")) s3.put_object( Bucket=opts["aws_bucket"], Key=destination_directory, Body=vcon.dumps() ) @@ -69,7 +84,7 @@ def get(vcon_uuid: str, opts=default_options) -> Optional[dict]: try: s3 = _create_s3_client(opts) - key = _build_s3_key(vcon_uuid, opts.get("s3_path")) + key = _build_s3_key(vcon_uuid, s3_path=opts.get("s3_path")) response = s3.get_object(Bucket=opts["aws_bucket"], Key=key) return json.loads(response['Body'].read().decode('utf-8')) diff --git a/tests/storage/test_s3.py b/tests/storage/test_s3.py index 70d5d70..34cbb1a 100644 --- a/tests/storage/test_s3.py +++ b/tests/storage/test_s3.py @@ -186,36 +186,51 @@ def test_create_client_with_various_aws_regions(self): class TestBuildS3Key: """Tests for the _build_s3_key helper function.""" - def test_build_key_without_prefix(self): - """Test key building without s3_path prefix.""" + def test_build_key_without_prefix_or_date(self): + """Test key building without s3_path prefix or created_at.""" key = _build_s3_key("test-uuid") assert key == "test-uuid.vcon" - def test_build_key_with_prefix(self): - """Test key building with s3_path prefix.""" - key = _build_s3_key("test-uuid", "vcons") + def test_build_key_with_prefix_only(self): + """Test key building with s3_path prefix but no created_at.""" + key = _build_s3_key("test-uuid", s3_path="vcons") assert key == "vcons/test-uuid.vcon" def test_build_key_with_trailing_slash_prefix(self): """Test key building with trailing slash in prefix.""" - key = _build_s3_key("test-uuid", "vcons/") + key = _build_s3_key("test-uuid", s3_path="vcons/") assert key == "vcons/test-uuid.vcon" def test_build_key_with_none_prefix(self): """Test key building with None prefix.""" - key = _build_s3_key("test-uuid", None) + key = _build_s3_key("test-uuid", s3_path=None) assert key == "test-uuid.vcon" def test_build_key_with_empty_prefix(self): """Test key building with empty string prefix.""" - key = _build_s3_key("test-uuid", "") + key = _build_s3_key("test-uuid", s3_path="") assert key == "test-uuid.vcon" def test_build_key_with_nested_prefix(self): """Test key building with nested prefix.""" - key = _build_s3_key("test-uuid", "data/vcons/archive") + key = _build_s3_key("test-uuid", s3_path="data/vcons/archive") assert key == "data/vcons/archive/test-uuid.vcon" + def test_build_key_with_created_at(self): + """Test key building with created_at generates date folder.""" + key = _build_s3_key("test-uuid", created_at="2025-12-10T15:30:00Z") + assert key == "2025/12/10/test-uuid.vcon" + + def test_build_key_with_created_at_and_prefix(self): + """Test key building with both created_at and s3_path.""" + key = _build_s3_key("test-uuid", created_at="2025-12-10T15:30:00Z", s3_path="vcons") + assert key == "vcons/2025/12/10/test-uuid.vcon" + + def test_build_key_with_created_at_and_nested_prefix(self): + """Test key building with created_at and nested prefix.""" + key = _build_s3_key("test-uuid", created_at="2024-01-15T08:00:00Z", s3_path="data/archive") + assert key == "data/archive/2024/01/15/test-uuid.vcon" + class TestSave: """Tests for the save function.""" @@ -225,6 +240,7 @@ def mock_vcon(self): """Create a mock vCon object.""" mock = MagicMock() mock.dumps.return_value = '{"uuid": "test-uuid", "vcon": "1.0.0"}' + mock.created_at = "2025-12-10T15:30:00Z" return mock @pytest.fixture @@ -237,7 +253,7 @@ def base_opts(self): } def test_save_basic(self, mock_vcon, base_opts): - """Test basic save operation.""" + """Test basic save operation with date folder.""" with patch("server.storage.s3.VconRedis") as mock_redis_class, \ patch("server.storage.s3.boto3.client") as mock_boto_client: @@ -255,10 +271,10 @@ def test_save_basic(self, mock_vcon, base_opts): call_args = mock_s3.put_object.call_args assert call_args.kwargs["Bucket"] == "test-bucket" - assert call_args.kwargs["Key"] == "test-uuid.vcon" + assert call_args.kwargs["Key"] == "2025/12/10/test-uuid.vcon" def test_save_with_s3_path_prefix(self, mock_vcon, base_opts): - """Test save operation with s3_path prefix.""" + """Test save operation with s3_path prefix and date folder.""" base_opts["s3_path"] = "vcons" with patch("server.storage.s3.VconRedis") as mock_redis_class, \ @@ -274,7 +290,7 @@ def test_save_with_s3_path_prefix(self, mock_vcon, base_opts): save("test-uuid", base_opts) call_args = mock_s3.put_object.call_args - assert call_args.kwargs["Key"] == "vcons/test-uuid.vcon" + assert call_args.kwargs["Key"] == "vcons/2025/12/10/test-uuid.vcon" def test_save_with_region(self, mock_vcon, base_opts): """Test save operation with region specified."""