Repository: voxel51/fiftyone

Issue:
[BUG] Group sample in modal missing attributes
In `0.21.6`, a group sample in the modal will be missing attribute data unless the fields are added via `add_dynamic_sample_fields()` or `add_dynamic_frame_fields()`. This is due a subtle detail in the `relay` cache, which is straightforward to resolve.

```py
import fiftyone as fo

dataset = foz.load_zoo_dataset("quickstart-groups")

for sample in dataset:
    for detection in sample.ground_truth.detections:
        detection["my_attr"] = "value"
    sample.save()

# tooltip will not contain `my_attr` on detections
session = fo.launch_app(dataset)


# will force `my_attr` to appear
dataset.add_dynamic_sample_fields()
```


Discussion:

Resolving patch:
diff --git a/fiftyone/server/routes/media.py b/fiftyone/server/routes/media.py
--- a/fiftyone/server/routes/media.py
+++ b/fiftyone/server/routes/media.py
@@ -5,8 +5,10 @@
 | `voxel51.com <https://voxel51.com/>`_
 |
 """
+import os
 import typing as t
 
+import anyio
 import aiofiles
 from aiofiles.threadpool.binary import AsyncBufferedReader
 from aiofiles.os import stat as aio_stat
@@ -58,6 +60,12 @@ async def get(
         path = request.query_params["filepath"]
 
         response: t.Union[FileResponse, StreamingResponse]
+
+        try:
+            await anyio.to_thread.run_sync(os.stat, path)
+        except FileNotFoundError:
+            return Response(content="Not found", status_code=404)
+
         if request.headers.get("range"):
             response = await self.ranged_file_response(path, request)
         else:
diff --git a/fiftyone/server/samples.py b/fiftyone/server/samples.py
--- a/fiftyone/server/samples.py
+++ b/fiftyone/server/samples.py
@@ -129,7 +129,9 @@ async def paginate_samples(
     url_cache = {}
     nodes = await asyncio.gather(
         *[
-            _create_sample_item(view, sample, metadata_cache, url_cache)
+            _create_sample_item(
+                view, sample, metadata_cache, url_cache, pagination_data
+            )
             for sample in samples
         ]
     )
@@ -159,6 +161,7 @@ async def _create_sample_item(
     sample: t.Dict,
     metadata_cache: t.Dict[str, t.Dict],
     url_cache: t.Dict[str, str],
+    pagination_data: bool,
 ) -> SampleItem:
     media_type = fom.get_media_type(sample["filepath"])
 
@@ -178,4 +181,9 @@ async def _create_sample_item(
     if cls == VideoSample:
         metadata = dict(**metadata, frame_number=sample.get("frame_number", 1))
 
-    return from_dict(cls, {"id": sample["_id"], "sample": sample, **metadata})
+    _id = sample["_id"]
+
+    if not pagination_data:
+        _id = f"{_id}-modal"
+
+    return from_dict(cls, {"id": _id, "sample": sample, **metadata})


Test patch:
diff --git a/tests/unittests/server_tests.py b/tests/unittests/server_tests.py
--- a/tests/unittests/server_tests.py
+++ b/tests/unittests/server_tests.py
@@ -819,6 +819,7 @@ async def test_disjoint_groups(self):
                     slice="first", id=first.group.id, slices=["first"]
                 )
             ),
+            pagination_data=True,
         )
         self.assertEqual(len(first_samples.edges), 1)
         self.assertEqual(first_samples.edges[0].node.id, first._id)
@@ -833,6 +834,7 @@ async def test_disjoint_groups(self):
                     slice="second", id=second.group.id, slices=["second"]
                 )
             ),
+            pagination_data=True,
         )
         self.assertEqual(len(second_samples.edges), 1)
         self.assertEqual(second_samples.edges[0].node.id, second._id)