diff --git a/picamera_coverage.zip b/picamera_coverage.zip index e47b59ea..0c04bf3d 100644 Binary files a/picamera_coverage.zip and b/picamera_coverage.zip differ diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 8a2644f1..5a39f11c 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -295,11 +295,6 @@ class BaseCamera(OFMThing, ABC): # 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.""" diff --git a/src/openflexure_microscope_server/things/gallery.py b/src/openflexure_microscope_server/things/gallery.py index 2f701c73..23f4f1bd 100644 --- a/src/openflexure_microscope_server/things/gallery.py +++ b/src/openflexure_microscope_server/things/gallery.py @@ -23,14 +23,14 @@ class GalleryCompatibleThing(Protocol): be picked up, but may throw an error when used. """ + name: str + # Ensure it is a Thing: _thing_server_interface: lt.ThingServerInterface # Ensure it is an OFMThing: show_data_in_gallery: bool - gallery_data_name: str - gallery_data_schema: type[BaseModel] # Ignore D102: No docstrings for the protocol. @@ -44,7 +44,9 @@ class GalleryThing(lt.Thing): all_ofm_things: Mapping[str, OFMThing] = lt.thing_slot() - _gallery_providing_things: Optional[Mapping[str, GalleryCompatibleThing]] = None + _gallery_providing_things: Optional[dict[str, GalleryCompatibleThing]] = None + _card_types: Optional[list[str]] = None + _card_type_map: dict[str, str] = {} @property def gallery_providing_things(self) -> Mapping[str, GalleryCompatibleThing]: @@ -83,8 +85,45 @@ class GalleryThing(lt.Thing): # Cast the type of each thing to "GalleryCompatibleThing" as other Things have # been popped. self._gallery_providing_things = cast( - Mapping[str, GalleryCompatibleThing], gallery_providers + dict[str, GalleryCompatibleThing], gallery_providers ) + self._set_card_types(self._gallery_providing_things) + + def _set_card_types( + self, gallery_providers: dict[str, GalleryCompatibleThing] + ) -> None: + """Find the card types from each provider. + + :param gallery_providers: The dictionary of gallery providing things. If card + data cannot be extracted for a Thing it will be popped from the provider + dictionary. + """ + card_types: list[str] = [] + # cache initial list of keys as it may change in the loop + keys = list(gallery_providers.keys()) + for key in keys: + card_schema = gallery_providers[key].gallery_data_schema.schema() + props = card_schema["properties"] + if "card_type" not in props or "const" not in props["card_type"]: + self.logger.error( + f"Data from {key} cannot be shown in gallery as data card doesn't " + "have a static card_type." + ) + gallery_providers.pop(key) + continue + card_type = props["card_type"]["const"] + if card_type not in card_types: + card_types.append(card_type) + self._card_type_map[key] = card_type + + self._card_types = card_types + + @lt.property + def card_types(self) -> list[str]: + """Names for the card types in the gallery.""" + if self._card_types is None: + raise RuntimeError("Cannot access card_types before server has started.") + return self._card_types @lt.property def list_data(self) -> list[dict[str, Any]]: @@ -108,10 +147,12 @@ class GalleryThing(lt.Thing): return data_list @lt.action - def delete_all_data(self) -> None: - """Delete all the gallery data on this microscope.""" + def delete_all_data(self, card_types: list[str]) -> None: + """Delete all the gallery data on this microscope with the given card types.""" for thing in self.gallery_providing_things.values(): - try: - thing.delete_all_gallery_items() - except Exception as e: - self.logger.exception(e) + thing_card_type = self._card_type_map.get(thing.name) + if thing_card_type in card_types: + 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 e51c84eb..d22cb55f 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -170,11 +170,6 @@ class SmartScanThing(OFMThing): # Register with gallery. _show_data_in_gallery = True - @property - def gallery_data_name(self) -> str: - """Name under which data shows up in gallery.""" - return "Scans" - @property def gallery_data_schema(self) -> type[scan_directories.ScanInfo]: """The schema (BaseModel) for passing data to the gallery.""" diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py index 7baf8f2f..2a099185 100644 --- a/tests/unit_tests/test_gallery.py +++ b/tests/unit_tests/test_gallery.py @@ -2,7 +2,7 @@ import logging from dataclasses import dataclass -from typing import Callable, Optional +from typing import Callable, Literal, Optional import pytest from pydantic import BaseModel @@ -19,14 +19,19 @@ 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.""" +def test_error_if_accessing_properties_before_started(): + """Check that unfefined properties errors before enter.""" thing = create_thing_without_server(GalleryThing) with pytest.raises( RuntimeError, match="Cannot access gallery_providing_things before server has started.", ): thing.gallery_providing_things + with pytest.raises( + RuntimeError, + match="Cannot access card_types before server has started.", + ): + thing.card_types class MockGalleryData(BaseModel): @@ -34,6 +39,13 @@ class MockGalleryData(BaseModel): mock: str foobar: int + card_type: Literal["Mock"] = "Mock" + + +class MockGalleryData2(MockGalleryData): + """Mock gallery data with a different card name.""" + + card_type: Literal["Mock2"] = "Mock2" class MinimalGalleryClass: @@ -42,13 +54,11 @@ class MinimalGalleryClass: This should fail the protocol check as it is not an OFMThing """ - gallery_data_name: str = "Mock" - gallery_data_schema: type[BaseModel] = MockGalleryData def get_data_for_gallery(self) -> list[BaseModel]: """Return a list of Mock Gallery Data.""" - return [MockGalleryData(mock="mock", foobar="foobar")] + return [self.gallery_data_schema(mock="mock", foobar="foobar")] def delete_all_gallery_items(self) -> None: """Mock deleting all the data.""" @@ -63,16 +73,20 @@ class MinimalGallerySource(OFMThing, MinimalGalleryClass): show_data_in_gallery = True +class MinimalGallerySource2(MinimalGallerySource): + """Another Thing that can show data in the gallery with a different card type.""" + + gallery_data_schema: type[BaseModel] = MockGalleryData2 + + class BadGallerySource(OFMThing): """An OFMThing that almost defines enough to show in the gallery. - Shouldn't match the protocol as ``gallery_data_name`` is missing. + Shouldn't match the protocol as ``gallery_data_schema`` is missing. """ show_data_in_gallery = True - gallery_data_schema: type[BaseModel] = MockGalleryData - def get_data_for_gallery(self) -> list[BaseModel]: """Return a list of Mock Gallery Data.""" return [MockGalleryData(mock="mock", foobar="foobar")] @@ -81,6 +95,31 @@ class BadGallerySource(OFMThing): """Mock deleting all the data.""" +class BadMockGalleryData(BaseModel): + """Mock gallery data without a card type.""" + + mock: str + foobar: int + + +class BadGallerySource2(OFMThing): + """An OFMThing that defines enough to show in the gallery with bad card data. + + Matches the protocol but will be rejected when setting card types. + """ + + show_data_in_gallery = True + + gallery_data_schema: type[BaseModel] = BadMockGalleryData + + def get_data_for_gallery(self) -> list[BaseModel]: + """Return a list of Mock Gallery Data.""" + return [BadMockGalleryData(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.""" # Minimal class has the gallery specific methods but is not an OFMThing. @@ -118,18 +157,23 @@ def test_gallery_thing_finds_all_providers(simulation_test_env): assert camera in gallery.gallery_providing_things.values() assert smart_scan in gallery.gallery_providing_things.values() + # Also check the card types: + assert gallery.card_types == ["Capture", "Scan"] + assert gallery._card_type_map == {"camera": "Capture", "smart_scan": "Scan"} + # Also check the runtime checkable protocol GalleryCompatibleThing works directly # when called with isinstance. assert not isinstance(snake_workflow, GalleryCompatibleThing) assert isinstance(smart_scan, GalleryCompatibleThing) -def test_gallery_logs_for_bad_providers(caplog): +@pytest.mark.parametrize("bad_source_class", [BadGallerySource, BadGallerySource2]) +def test_gallery_logs_for_bad_providers(bad_source_class, caplog): """Test that an error is logged if a gallery source doesn't match the protocol.""" # Create a config with the minimal Thing and the bad Thing things = { "minimal_thing": MinimalGallerySource, - "bad_thing": BadGallerySource, + "bad_thing": bad_source_class, "gallery": GalleryThing, } with caplog.at_level(logging.INFO): @@ -155,7 +199,7 @@ def minimal_gallery_env(): """Return a minimal working environment for testing the gallery.""" things = { "minimal_thing1": MinimalGallerySource, - "minimal_thing2": MinimalGallerySource, + "minimal_thing2": MinimalGallerySource2, "gallery": GalleryThing, } with LabThingsTestEnv( @@ -178,8 +222,8 @@ def test_gallery_lists_data(minimal_gallery_env, mocker, caplog): ] thing_2_data = [ - MockGalleryData(mock="thing_2", foobar=1), - MockGalleryData(mock="thing_2", foobar=2), + MockGalleryData2(mock="thing_2", foobar=1), + MockGalleryData2(mock="thing_2", foobar=2), ] # Also mock the get_data_for_gallery methods. @@ -188,12 +232,12 @@ def test_gallery_lists_data(minimal_gallery_env, mocker, caplog): # Set the expected returns. thing_1_return = [ - {"mock": "thing_1", "foobar": 1}, - {"mock": "thing_1", "foobar": 2}, + {"mock": "thing_1", "foobar": 1, "card_type": "Mock"}, + {"mock": "thing_1", "foobar": 2, "card_type": "Mock"}, ] thing_2_return = [ - {"mock": "thing_2", "foobar": 1}, - {"mock": "thing_2", "foobar": 2}, + {"mock": "thing_2", "foobar": 1, "card_type": "Mock2"}, + {"mock": "thing_2", "foobar": 2, "card_type": "Mock2"}, ] # Check that the gallery returns the expected concatenated data @@ -216,24 +260,53 @@ class GalleryDeleteTestCase: The default save kwargs assume a jpeg. """ + cards_to_delete: list[str] thing_1_side_effect: Optional[Callable] + thing_1_call_count: int 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 + # Delete both types of card. GalleryDeleteTestCase( - thing_1_side_effect=None, thing_2_call_count=1, action_errors=False + cards_to_delete=["Mock", "Mock2"], + thing_1_side_effect=None, + thing_1_call_count=1, + thing_2_call_count=1, + action_errors=False, + ), + # Delete first type of card. + GalleryDeleteTestCase( + cards_to_delete=["Mock"], + thing_1_side_effect=None, + thing_1_call_count=1, + thing_2_call_count=0, + action_errors=False, + ), + # Delete second type of card. + GalleryDeleteTestCase( + cards_to_delete=["Mock2"], + thing_1_side_effect=None, + thing_1_call_count=0, + 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 + cards_to_delete=["Mock", "Mock2"], + thing_1_side_effect=RuntimeError, + thing_1_call_count=1, + thing_2_call_count=1, + action_errors=False, ), - # Thing 1 raises invocation cancelled error. Thing 2 is not called. The InvocationError is raised + # Thing 1 raises invocation cancelled error. Thing 2 is not called. + # The InvocationError is raised GalleryDeleteTestCase( + cards_to_delete=["Mock", "Mock2"], thing_1_side_effect=InvocationCancelledError, + thing_1_call_count=1, thing_2_call_count=0, action_errors=True, ), @@ -257,10 +330,10 @@ def test_gallery_calls_delete(test_case, minimal_gallery_env, mocker): # 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() + gallery.delete_all_data(test_case.cards_to_delete) else: - gallery.delete_all_data() + gallery.delete_all_data(test_case.cards_to_delete) # Check the call counts are as expected from the test case. - assert thing_1.delete_all_gallery_items.call_count == 1 + assert thing_1.delete_all_gallery_items.call_count == test_case.thing_1_call_count 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 0be10c6d..cc8f5bba 100644 --- a/tests/unit_tests/test_simulated_camera.py +++ b/tests/unit_tests/test_simulated_camera.py @@ -297,7 +297,6 @@ def test_gallery_responses(camera, mocker, caplog): 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 diff --git a/webapp/src/assets/less/variables.less b/webapp/src/assets/less/variables.less index 8f2df722..ead4c33a 100644 --- a/webapp/src/assets/less/variables.less +++ b/webapp/src/assets/less/variables.less @@ -249,6 +249,48 @@ } } +// A button styled like text. For use in menus +.ofm-text-button { + display: block; + background: none; + border: none; + padding: 8px 10px; + cursor: pointer; + color: @global-color; + width: 100%; + text-align: left; + font-weight: 600; +} +.hook-inverse() { + .ofm-text-button{ + color: @inverse-global-color; + } +} + +/* + * Opaque - button-like colours + * + * The buttons are transparent. If an element overlays others it should + * Look the same as the buttons, but have no transparency. + */ +.ofm-opaque-element{ + background: #f5f5f5; +} +.hook-inverse() { + .ofm-opaque-element{ + background: #282626; + } +} + +.ofm-opaque-highlight:hover { + background: #fff; +} +.hook-inverse() { + .ofm-opaque-highlight:hover { + background: #000; + } +} + /* * Accordion */ diff --git a/webapp/src/components/appContent.vue b/webapp/src/components/appContent.vue index d5fac1a0..61051a6c 100644 --- a/webapp/src/components/appContent.vue +++ b/webapp/src/components/appContent.vue @@ -267,6 +267,7 @@ export default { #container-left { overflow: auto; + scrollbar-gutter: stable; background-color: rgba(180, 180, 180, 0.025); width: 100%; height: 100%; diff --git a/webapp/src/components/genericComponents/multiSelectDropdown.vue b/webapp/src/components/genericComponents/multiSelectDropdown.vue new file mode 100644 index 00000000..6f89e866 --- /dev/null +++ b/webapp/src/components/genericComponents/multiSelectDropdown.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/webapp/src/components/tabContentComponents/galleryContent.vue b/webapp/src/components/tabContentComponents/galleryContent.vue index 45f8f2ac..65da50bd 100644 --- a/webapp/src/components/tabContentComponents/galleryContent.vue +++ b/webapp/src/components/tabContentComponents/galleryContent.vue @@ -1,10 +1,17 @@