diff --git a/picamera_coverage.zip b/picamera_coverage.zip index 777a12e1..e47b59ea 100644 Binary files a/picamera_coverage.zip and b/picamera_coverage.zip differ diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index da852438..0505e254 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -8,7 +8,7 @@ import shutil import threading import zipfile from datetime import datetime, timedelta -from typing import Any, Mapping, Optional, Self +from typing import Any, Literal, Mapping, Optional, Self from pydantic import ( BaseModel, @@ -55,6 +55,8 @@ class ScanInfo(BaseModel): number_of_images: int stitch_available: bool dzi: Optional[str] + thing: str = "smart_scan" + card_type: Literal["Scan"] = "Scan" class BaseScanData(BaseModel): diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index b85b970e..8a2644f1 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -20,7 +20,7 @@ from typing import Any, Literal, Mapping, Optional, Self import numpy as np import piexif -from fastapi import Response +from fastapi import HTTPException, Response from PIL import Image from pydantic import BaseModel @@ -62,6 +62,13 @@ BASE_IMAGE_FORMATS: dict[str, ImageFormatInfo] = { } +def _file_is_capture(filename: str) -> bool: + """Return whether this filename is a capture.""" + return BASE_IMAGE_FORMATS["jpeg"].path_matches(filename) or BASE_IMAGE_FORMATS[ + "png" + ].path_matches(filename) + + class CaptureError(RuntimeError): """An error trying to capture from a CameraThing.""" @@ -199,6 +206,16 @@ class CaptureMode(BaseModel): """The resolution to save the image. Use None to save as captured.""" +class CaptureInfo(BaseModel): + """Summary information for the UI about an image.""" + + name: str + created: float + modified: float + thing: str + card_type: Literal["Capture"] = "Capture" + + class BaseCamera(OFMThing, ABC): """The base class for all cameras. All cameras must directly inherit from this class. @@ -275,6 +292,79 @@ class BaseCamera(OFMThing, ABC): jpeg_data = await self.lores_mjpeg_stream.grab_frame() return Response(content=jpeg_data, media_type="image/jpeg") + # Register with gallery. + _show_data_in_gallery = True + + @property + def gallery_data_name(self) -> str: + """Name under which data shows up in gallery.""" + return "Captures" + + @property + def gallery_data_schema(self) -> type[CaptureInfo]: + """The schema (BaseModel) for passing data to the gallery.""" + return CaptureInfo + + def _all_captures(self) -> list[str]: + """Return the full path for all captures on disk.""" + files = os.listdir(self._data_dir) + captures = [] + for filename in files: + full_path = os.path.join(self.data_dir, filename) + if os.path.isfile(full_path) and _file_is_capture(filename): + captures.append(full_path) + return captures + + def get_data_for_gallery(self) -> list[CaptureInfo]: + """Return all the information about the saved captures.""" + return [ + CaptureInfo( + name=os.path.basename(capture), + created=os.path.getctime(capture), + modified=os.path.getmtime(capture), + thing=self.name, + ) + for capture in self._all_captures() + ] + + def delete_all_gallery_items(self) -> None: + """Delete all the captures on the microscope. + + Use with extreme caution. + """ + for capture in self._all_captures(): + lt.raise_if_cancelled() + self.logger.info(f"Deleting: {capture}") + os.remove(capture) + + @lt.endpoint( + "delete", + "capture/{name}", + responses={ + 200: {"description": "Successfully deleted capture"}, + 400: {"description": "An error occurred while trying to delete capture"}, + }, + ) + def delete_capture(self, name: str) -> None: + """Delete the specified capture. + + This endpoint allows captures to be deleted from disk. + + :param name: The name of the capture to delete + """ + if not _file_is_capture(name): + self.logger.warning(f"{name} is not an image file.") + raise HTTPException(400, f"{name} is not an image file.") + full_path = os.path.normpath(os.path.join(self.data_dir, name)) + + try: + os.remove(full_path) + except IOError as e: + self.logger.warning(f"Failed to delete {name}.") + raise HTTPException( + 400, "Couldn't delete capture, check log for details" + ) from e + @property def focus_fom(self) -> int: """Return the focus figure of merit. diff --git a/src/openflexure_microscope_server/things/gallery.py b/src/openflexure_microscope_server/things/gallery.py index 88a6eaed..2f701c73 100644 --- a/src/openflexure_microscope_server/things/gallery.py +++ b/src/openflexure_microscope_server/things/gallery.py @@ -36,6 +36,8 @@ class GalleryCompatibleThing(Protocol): # Ignore D102: No docstrings for the protocol. def get_data_for_gallery(self) -> list[BaseModel]: ... # noqa: D102 + def delete_all_gallery_items(self) -> None: ... # noqa: D102 + class GalleryThing(lt.Thing): """A Thing for communicating with the front end gallery.""" @@ -95,5 +97,21 @@ class GalleryThing(lt.Thing): """ data_list = [] for thing in self.gallery_providing_things.values(): - data_list += [model.model_dump() for model in thing.get_data_for_gallery()] + try: + data_list += [ + model.model_dump() for model in thing.get_data_for_gallery() + ] + except Exception as e: + # If any of the providers errors producing a list, log the exception + # and continue. + self.logger.exception(e) return data_list + + @lt.action + def delete_all_data(self) -> None: + """Delete all the gallery data on this microscope.""" + for thing in self.gallery_providing_things.values(): + try: + thing.delete_all_gallery_items() + except Exception as e: + self.logger.exception(e) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 328a76cb..e51c84eb 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -192,6 +192,18 @@ class SmartScanThing(OFMThing): ongoing=ongoing_name, include_ongoing=False ) + def delete_all_gallery_items(self) -> None: + """Delete all the scans on the microscope. + + **This will irreversibly remove all scanned data from the microscope!** + + Use with extreme caution. + """ + for scan_name in self._scan_dir_manager.all_scans: + lt.raise_if_cancelled() + self.logger.info(f"Deleting: {scan_name}") + self._delete_scan(scan_name) + # Note that the default detector name is set at init. This is over written if # setting is loaded from disk. @lt.setting @@ -608,20 +620,6 @@ class SmartScanThing(OFMThing): if not deleted_scan_success: raise HTTPException(400, "Couldn't delete scan, check log for details") - @lt.endpoint( - "delete", - "scans", - ) - def delete_all_scans(self) -> None: - """Delete all the scans on the microscope. - - **This will irreversibly remove all scanned data from the - microscope!** - Use with extreme caution. - """ - for scan_name in self._scan_dir_manager.all_scans: - self._delete_scan(scan_name) - @lt.action def purge_empty_scans(self) -> None: """Delete all scan folders containing no images at the top level.""" diff --git a/tests/conftest.py b/tests/conftest.py index cbeda0c1..09372412 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,21 @@ REPO_ROOT = os.path.dirname(THIS_DIR) SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json") +@pytest.fixture(autouse=True) +def restore_root_logger(): + """Restore the logging level of the root logger after each test. + + As we change the root logging level if any code runs ``configure_logging`` + this can make logs leak between tests. + """ + root = logging.getLogger() + old_level = root.level + + yield + + root.setLevel(old_level) + + @pytest.fixture def simulation_test_env(): """Yield a server with the configuration from the simulation json.""" diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py index 10bc246d..7baf8f2f 100644 --- a/tests/unit_tests/test_gallery.py +++ b/tests/unit_tests/test_gallery.py @@ -1,9 +1,13 @@ """Tests that captures have the expected metadata.""" import logging +from dataclasses import dataclass +from typing import Callable, Optional +import pytest from pydantic import BaseModel +from labthings_fastapi.exceptions import InvocationCancelledError from labthings_fastapi.testing import create_thing_without_server from openflexure_microscope_server.things import OFMThing @@ -15,6 +19,16 @@ from openflexure_microscope_server.things.gallery import ( from ..shared_utils.lt_test_utils import LabThingsTestEnv +def test_error_if_accessing_gallery_things_before_started(): + """Check that gallery_providing_things property errors before init.""" + thing = create_thing_without_server(GalleryThing) + with pytest.raises( + RuntimeError, + match="Cannot access gallery_providing_things before server has started.", + ): + thing.gallery_providing_things + + class MockGalleryData(BaseModel): """Mock gallery data.""" @@ -36,6 +50,9 @@ class MinimalGalleryClass: """Return a list of Mock Gallery Data.""" return [MockGalleryData(mock="mock", foobar="foobar")] + def delete_all_gallery_items(self) -> None: + """Mock deleting all the data.""" + class MinimalGallerySource(OFMThing, MinimalGalleryClass): """Minimal example of a Thing that can show data in the gallery. @@ -60,6 +77,9 @@ class BadGallerySource(OFMThing): """Return a list of Mock Gallery Data.""" return [MockGalleryData(mock="mock", foobar="foobar")] + def delete_all_gallery_items(self) -> None: + """Mock deleting all the data.""" + def test_gallery_compatible_protocol(): """Check the gallery compatible protocol detects compatible classes only.""" @@ -79,19 +99,27 @@ def test_gallery_compatible_protocol(): def test_gallery_thing_finds_all_providers(simulation_test_env): """Check the gallery identifies the correct Things that will provide data.""" + # Started the full simulation in a test environment. + # Get the gallery and 3 other Things from the environment. gallery = simulation_test_env.get_thing_by_name("gallery") snake_workflow = simulation_test_env.get_thing_by_name("snake_workflow") smart_scan = simulation_test_env.get_thing_by_name("smart_scan") camera = simulation_test_env.get_thing_by_name("camera") + # Check that all_ofm_things are correct. Camera and smart scan are ofm things + # snake workflow is not. assert snake_workflow not in gallery.all_ofm_things.values() assert camera in gallery.all_ofm_things.values() assert smart_scan in gallery.all_ofm_things.values() + # Also check that both the camera and smart scan are recognised as gallery + # providers based on the protocol. assert snake_workflow not in gallery.gallery_providing_things.values() - assert camera not in gallery.gallery_providing_things.values() + assert camera in gallery.gallery_providing_things.values() assert smart_scan in gallery.gallery_providing_things.values() + # Also check the runtime checkable protocol GalleryCompatibleThing works directly + # when called with isinstance. assert not isinstance(snake_workflow, GalleryCompatibleThing) assert isinstance(smart_scan, GalleryCompatibleThing) @@ -120,3 +148,119 @@ def test_gallery_logs_for_bad_providers(caplog): record = caplog.records[0] assert "Data from bad_thing cannot be shown in gallery" in record.message assert record.levelname == "ERROR" + + +@pytest.fixture +def minimal_gallery_env(): + """Return a minimal working environment for testing the gallery.""" + things = { + "minimal_thing1": MinimalGallerySource, + "minimal_thing2": MinimalGallerySource, + "gallery": GalleryThing, + } + with LabThingsTestEnv( + things=things, application_config={"data_folder": "./openflexure/data/"} + ) as env: + return env + + +def test_gallery_lists_data(minimal_gallery_env, mocker, caplog): + """Test that the gallery calls all gallery things to list data.""" + # Create a config with the two minimal things and get the things by name. + gallery = minimal_gallery_env.get_thing_by_name("gallery") + thing_1 = minimal_gallery_env.get_thing_by_name("minimal_thing1") + thing_2 = minimal_gallery_env.get_thing_by_name("minimal_thing2") + + # Add some mock data to them the gallery data things + thing_1_data = [ + MockGalleryData(mock="thing_1", foobar=1), + MockGalleryData(mock="thing_1", foobar=2), + ] + + thing_2_data = [ + MockGalleryData(mock="thing_2", foobar=1), + MockGalleryData(mock="thing_2", foobar=2), + ] + + # Also mock the get_data_for_gallery methods. + mocker.patch.object(thing_1, "get_data_for_gallery", return_value=thing_1_data) + mocker.patch.object(thing_2, "get_data_for_gallery", return_value=thing_2_data) + + # Set the expected returns. + thing_1_return = [ + {"mock": "thing_1", "foobar": 1}, + {"mock": "thing_1", "foobar": 2}, + ] + thing_2_return = [ + {"mock": "thing_2", "foobar": 1}, + {"mock": "thing_2", "foobar": 2}, + ] + + # Check that the gallery returns the expected concatenated data + assert gallery.list_data == thing_1_return + thing_2_return + + # Check that if the first thing called throws an error the data from the second + # thing is still returned. + thing_1.get_data_for_gallery.side_effect = RuntimeError + with caplog.at_level(logging.INFO): + assert gallery.list_data == thing_2_return + # Check an error was logged about the first thing failing to collect data. + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "ERROR" + + +@dataclass +class GalleryDeleteTestCase: + """Inputs and expected outputs for testing ``save_from_memory``. + + The default save kwargs assume a jpeg. + """ + + thing_1_side_effect: Optional[Callable] + thing_2_call_count: int + # Expect directly calling the action will raise the thing 1 side_effect error + action_errors: bool + + +GALLERY_DELETE_TEST_CASES = [ + # No errors from thing 1, thing 2 gets called + GalleryDeleteTestCase( + thing_1_side_effect=None, thing_2_call_count=1, action_errors=False + ), + # Thing 1 errors, thing 2 is still called + GalleryDeleteTestCase( + thing_1_side_effect=RuntimeError, thing_2_call_count=1, action_errors=False + ), + # Thing 1 raises invocation cancelled error. Thing 2 is not called. The InvocationError is raised + GalleryDeleteTestCase( + thing_1_side_effect=InvocationCancelledError, + thing_2_call_count=0, + action_errors=True, + ), +] + + +@pytest.mark.parametrize("test_case", GALLERY_DELETE_TEST_CASES) +def test_gallery_calls_delete(test_case, minimal_gallery_env, mocker): + """Test that the gallery deletes on all gallery providing things.""" + # Create a config with the minimal Thing and the bad Thing + gallery = minimal_gallery_env.get_thing_by_name("gallery") + thing_1 = minimal_gallery_env.get_thing_by_name("minimal_thing1") + thing_2 = minimal_gallery_env.get_thing_by_name("minimal_thing2") + + # Set up the desired side effect for the test case. + mocker.patch.object( + thing_1, "delete_all_gallery_items", side_effect=test_case.thing_1_side_effect + ) + mocker.patch.object(thing_2, "delete_all_gallery_items") + + # Call delete_all_data, checking for errors if the test case expects an error. + if test_case.action_errors: + with pytest.raises(test_case.thing_1_side_effect): + gallery.delete_all_data() + else: + gallery.delete_all_data() + + # Check the call counts are as expected from the test case. + assert thing_1.delete_all_gallery_items.call_count == 1 + assert thing_2.delete_all_gallery_items.call_count == test_case.thing_2_call_count diff --git a/tests/unit_tests/test_simulated_camera.py b/tests/unit_tests/test_simulated_camera.py index 8b93152d..0be10c6d 100644 --- a/tests/unit_tests/test_simulated_camera.py +++ b/tests/unit_tests/test_simulated_camera.py @@ -1,6 +1,13 @@ -"""Test the functionality specific to the simulated camera.""" +"""Test the the simulated camera. + +This includes both unit tests for functions specific to the simulation camera or +base camera functionality that is not camera specific, but requires the camera to +start and take images. +""" import logging +import os +import tempfile import time from io import BytesIO @@ -14,7 +21,7 @@ from pydantic import ValidationError import labthings_fastapi as lt from openflexure_microscope_server.things.background_detect import ChannelDeviationLUV -from openflexure_microscope_server.things.camera import simulation +from openflexure_microscope_server.things.camera import CaptureInfo, simulation from openflexure_microscope_server.things.camera.simulation import SimulatedCamera from openflexure_microscope_server.things.stage.dummy import DummyStage @@ -279,3 +286,110 @@ def test_generate_image_output_size(camera): img = camera.generate_image(pos) assert img.size == (camera.shape[1], camera.shape[0]) + + +def test_gallery_responses(camera, mocker, caplog): + """Check camera responds to gallery as expected.""" + mock_raise_if_cancelled = mocker.patch( + "openflexure_microscope_server.things.camera.lt.raise_if_cancelled", + ) + + with tempfile.TemporaryDirectory() as tmpdir, caplog.at_level(logging.INFO): + camera._data_dir = tmpdir + + assert camera.gallery_data_name == "Captures" + assert camera.gallery_data_schema == CaptureInfo + + # When we start there are no captures + assert camera.get_data_for_gallery() == [] + + filenames = [ + "doe.png", + "ray.png", + "me.jpg", + "far.jpeg", + "so.txt", + "la.json", + "tea.leaf", + ] + images = filenames[:4] + non_images = filenames[4:] + + for filename in filenames: + with open(os.path.join(camera._data_dir, filename), "w") as f_obj: + f_obj.write("mock") + + # Check all files written + assert set(os.listdir(camera._data_dir)) == set(filenames) + + # Check only images are returned by get_data_for_galler + captures = camera.get_data_for_gallery() + assert {capture.name for capture in captures} == set(images) + + # delete everything + camera.delete_all_gallery_items() + + # No captures remain + assert camera.get_data_for_gallery() == [] + + # Non-images not deleted + assert set(os.listdir(camera._data_dir)) == set(non_images) + + assert len(caplog.records) == len(images) + for record in caplog.records: + assert record.message.startswith("Deleting: ") + assert mock_raise_if_cancelled.call_count == len(images) + + +def test_delete_capture(test_env, caplog): + """Check camera deletes images when requested.""" + camera = test_env.get_thing_by_type(SimulatedCamera) + http_client = test_env.get_thing_client("camera").client + + with tempfile.TemporaryDirectory() as tmpdir, caplog.at_level(logging.WARNING): + cam_dir = os.path.join(tmpdir, "camera") + os.makedirs(cam_dir) + camera._data_dir = cam_dir + + filenames = [ + os.path.join(cam_dir, "foo.png"), + # Make an image outside the camera dir + os.path.join(tmpdir, "external.png"), + os.path.join(cam_dir, "bar.txt"), + ] + + for filename in filenames: + with open(filename, "w") as f_obj: + f_obj.write("mock") + + # Check files created. + assert "foo.png" in os.listdir(camera._data_dir) + assert "bar.txt" in os.listdir(camera._data_dir) + assert os.path.isfile(os.path.join(tmpdir, "external.png")) + + response = http_client.delete("camera/capture/foo.png") + assert response.status_code == 200 # OK + # File is gone + assert "foo.png" not in os.listdir(camera._data_dir) + # No longs + assert len(caplog.records) == 0 + + # Try to be evil and delete the file outside the cameras data dir" + response = http_client.delete(r"camera/capture/%2E%2E%2Ffoo.png") + assert response.status_code == 404 # Route not allowed by fastAPI + # No records + assert len(caplog.records) == 0 + + # Try to delete an image that doesn't exist + response = http_client.delete("camera/capture/fake.png") + assert response.status_code == 400 # Error deleting as it doesn't exist + # A new log + assert len(caplog.records) == 1 + + # Try to delete something that isn't an image. + response = http_client.delete("camera/capture/bar.txt") + assert response.status_code == 400 # Error deleting as it isn't and image + # File still exists. + assert "bar.txt" in os.listdir(camera._data_dir) + # A new log + assert len(caplog.records) == 2 diff --git a/tests/unit_tests/test_smart_scan.py b/tests/unit_tests/test_smart_scan.py index d8fe3038..404165da 100644 --- a/tests/unit_tests/test_smart_scan.py +++ b/tests/unit_tests/test_smart_scan.py @@ -281,8 +281,11 @@ def test_public_delete_scan(entered_smart_scan_thing, caplog): assert len(caplog.records) == 1 -def test_delete_all_scans(entered_smart_scan_thing, caplog): +def test_delete_all_scans(entered_smart_scan_thing, caplog, mocker): """Check the delete_all_scan API really does delete all the scans.""" + mock_raise_if_cancelled = mocker.patch( + "openflexure_microscope_server.things.smart_scan.lt.raise_if_cancelled", + ) smart_scan_thing = entered_smart_scan_thing with caplog.at_level(logging.INFO): fake_scan_names = [ @@ -295,12 +298,17 @@ def test_delete_all_scans(entered_smart_scan_thing, caplog): fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name) os.makedirs(fake_scan_path) assert os.path.exists(fake_scan_path) - smart_scan_thing.delete_all_scans() + smart_scan_thing.delete_all_gallery_items() for fake_scan_name in fake_scan_names: fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name) assert not os.path.exists(fake_scan_path) - # No logs generated - assert len(caplog.records) == 0 + # One log generated per scan deleted + assert len(caplog.records) == 4 + for record in caplog.records: + assert record.message.startswith("Deleting: fake_scan_000") + assert record.levelname == "INFO" + # Check that raise_if_cancelled is called once for each scan. + assert mock_raise_if_cancelled.call_count == 4 def _run_only_outer_scan( diff --git a/webapp/src/components/genericComponents/toggleSwitch.vue b/webapp/src/components/genericComponents/toggleSwitch.vue new file mode 100644 index 00000000..a3fb6746 --- /dev/null +++ b/webapp/src/components/genericComponents/toggleSwitch.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/webapp/src/components/tabContentComponents/controlComponents/captureControl.vue b/webapp/src/components/tabContentComponents/controlComponents/captureControl.vue new file mode 100644 index 00000000..ba3affd7 --- /dev/null +++ b/webapp/src/components/tabContentComponents/controlComponents/captureControl.vue @@ -0,0 +1,74 @@ + + + diff --git a/webapp/src/components/tabContentComponents/controlComponents/paneControl.vue b/webapp/src/components/tabContentComponents/controlComponents/paneControl.vue index b3a1b5fe..8799ca66 100644 --- a/webapp/src/components/tabContentComponents/controlComponents/paneControl.vue +++ b/webapp/src/components/tabContentComponents/controlComponents/paneControl.vue @@ -2,34 +2,20 @@
-

Image Capture

- -
- -
+
diff --git a/webapp/src/components/tabContentComponents/galleryComponents/galleryCard.vue b/webapp/src/components/tabContentComponents/galleryComponents/galleryCard.vue index 8e8af469..1d69591b 100644 --- a/webapp/src/components/tabContentComponents/galleryComponents/galleryCard.vue +++ b/webapp/src/components/tabContentComponents/galleryComponents/galleryCard.vue @@ -5,17 +5,17 @@
-