Merge branch 'image-capture' into 'v3'
Add a toggle switch for image capture to gallery See merge request openflexure/openflexure-microscope-server!599
This commit is contained in:
commit
21d0beedb9
17 changed files with 690 additions and 134 deletions
Binary file not shown.
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
123
webapp/src/components/genericComponents/toggleSwitch.vue
Normal file
123
webapp/src/components/genericComponents/toggleSwitch.vue
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
<div>
|
||||
<p v-if="labelText !== undefined" class="toggle-label uk-form-label">{{ labelText }}</p>
|
||||
<div class="toggle-switch" :class="{ checked: modelValue }" @click="toggle" />
|
||||
<p v-if="showStateLabel" class="toggle-state-label">{{ stateLabelText }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ToggleSwitch",
|
||||
|
||||
props: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
},
|
||||
labelText: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
falseLabel: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
trueLabel: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["update:modelValue"],
|
||||
|
||||
computed: {
|
||||
showStateLabel() {
|
||||
return this.trueLabel !== undefined && this.falseLabel !== undefined;
|
||||
},
|
||||
stateLabelText() {
|
||||
return this.modelValue ? this.trueLabel : this.falseLabel;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggle() {
|
||||
this.$emit("update:modelValue", !this.modelValue);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import url("../../assets/less/variables.less");
|
||||
|
||||
.toggle-label {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.toggle-state-label {
|
||||
margin-top: 0.2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
background: #ddd;
|
||||
border-radius: 12px;
|
||||
box-shadow: inset 1px 1px 1px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
transition: background-color 150ms;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.toggle-switch::before {
|
||||
background: #fff;
|
||||
background-image: radial-gradient(
|
||||
circle at 6px 6px,
|
||||
rgba(0, 0, 0, 0) 0,
|
||||
rgba(0, 0, 0, 0.05) 16px
|
||||
);
|
||||
border-radius: 10px;
|
||||
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.08);
|
||||
content: "";
|
||||
display: block;
|
||||
height: 20px;
|
||||
left: 2px;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
transition: left 150ms;
|
||||
width: 20px;
|
||||
will-change: left;
|
||||
}
|
||||
|
||||
.checked {
|
||||
background-color: @global-primary-background;
|
||||
}
|
||||
|
||||
.checked::before {
|
||||
background-image: radial-gradient(
|
||||
circle at 6px 6px,
|
||||
rgba(0, 0, 0, 0) 0,
|
||||
rgba(0, 0, 0, 0.05) 16px
|
||||
);
|
||||
left: 26px;
|
||||
}
|
||||
|
||||
.toggle-switch:hover {
|
||||
box-shadow: inset 1px 1px 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch:hover::before {
|
||||
background-image: radial-gradient(
|
||||
circle at 6px 6px,
|
||||
rgba(0, 0, 0, 0) 0,
|
||||
rgba(0, 0, 0, 0.1375) 16px
|
||||
);
|
||||
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<div id="captureControl">
|
||||
<p><b>Image Capture</b></p>
|
||||
|
||||
<toggle-switch
|
||||
v-model="saveToGallery"
|
||||
label-text="Save to Gallery?"
|
||||
true-label="Saving"
|
||||
false-label="Downloading"
|
||||
/>
|
||||
|
||||
<div class="uk-margin">
|
||||
<action-button
|
||||
thing="camera"
|
||||
action="capture"
|
||||
:submit-data="submitData"
|
||||
submit-label="Capture"
|
||||
:submit-on-event="'globalCaptureEvent'"
|
||||
@response="handleCaptureResponse"
|
||||
@error="modalError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import toggleSwitch from "@/components/genericComponents/toggleSwitch.vue";
|
||||
import ActionButton from "@/components/labThingsComponents/actionButton.vue";
|
||||
|
||||
export default {
|
||||
name: "CaptureControl",
|
||||
|
||||
components: {
|
||||
toggleSwitch,
|
||||
ActionButton,
|
||||
},
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
saveToGallery: true,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
submitData() {
|
||||
return {
|
||||
capture_mode: "standard",
|
||||
retain_image: this.saveToGallery,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleCaptureResponse: async function (response) {
|
||||
// Retrieve the captured image and save it
|
||||
if (this.saveToGallery) return;
|
||||
let imageUri = response.output.href;
|
||||
if (!imageUri) {
|
||||
this.modalError("No image URI returned from capture task.");
|
||||
return;
|
||||
}
|
||||
// To save the returned data, we make a virtual link and click it
|
||||
let imageResponse = await axios.get(imageUri, { responseType: "blob" });
|
||||
const url = window.URL.createObjectURL(new Blob([imageResponse.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", `OFM_${new Date().toISOString()}.jpeg`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -2,34 +2,20 @@
|
|||
<div id="paneControl" class="uk-padding-small">
|
||||
<positionControl v-if="stageAvailable" />
|
||||
<autofocusControl v-if="autofocusAvailable" />
|
||||
<p>Image Capture</p>
|
||||
|
||||
<div class="uk-margin">
|
||||
<action-button
|
||||
thing="camera"
|
||||
action="capture"
|
||||
:submit-data="{ capture_mode: 'standard' }"
|
||||
submit-label="Capture"
|
||||
:submit-on-event="'globalCaptureEvent'"
|
||||
@response="handleCaptureResponse"
|
||||
@error="modalError"
|
||||
/>
|
||||
</div>
|
||||
<captureControl />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import ActionButton from "../../labThingsComponents/actionButton.vue";
|
||||
import captureControl from "./captureControl.vue";
|
||||
import positionControl from "./positionControl.vue";
|
||||
import autofocusControl from "./autofocusControl.vue";
|
||||
import { eventBus } from "../../../eventBus.js";
|
||||
|
||||
export default {
|
||||
name: "PaneControl",
|
||||
|
||||
components: {
|
||||
ActionButton,
|
||||
captureControl,
|
||||
positionControl,
|
||||
autofocusControl,
|
||||
},
|
||||
|
|
@ -42,28 +28,5 @@ export default {
|
|||
return this.thingAvailable("autofocus");
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleCaptureResponse: async function (response) {
|
||||
// Retrieve the captured image and save it
|
||||
let imageUri = response.output.href;
|
||||
if (!imageUri) {
|
||||
this.modalError("No image URI returned from capture task.");
|
||||
return;
|
||||
}
|
||||
// Emit flash capture stream animation
|
||||
eventBus.emit("globalFlashStream");
|
||||
|
||||
// To save the returned data, we make a virtual link and click it
|
||||
let imageResponse = await axios.get(imageUri, { responseType: "blob" });
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([imageResponse.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", `OFM_${new Date().toISOString()}.jpeg`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@
|
|||
<div class="view-image uk-padding-remove uk-height-1-1">
|
||||
<img
|
||||
id="thumbnail-stitched-image"
|
||||
:class="[itemData?.dzi ? 'thumbnail-fit clickable' : 'thumbnail-fit disabled']"
|
||||
:class="[viewerAvailable ? 'thumbnail-fit clickable' : 'thumbnail-fit disabled']"
|
||||
class="thumbnail-fit"
|
||||
:src="thumbnailPath"
|
||||
onerror="this.src = '/titleiconpink.svg'"
|
||||
@click="itemData?.dzi && requestViewer()"
|
||||
@click="viewerAvailable && requestViewer()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="uk-card-title gallery-card-title">{{ itemData.name }}</h3>
|
||||
<div class="button-container">
|
||||
<div class="uk-button-group gallery-card-buttons">
|
||||
<div v-if="itemData.card_type === 'Scan'" class="uk-button-group gallery-card-buttons">
|
||||
<action-button
|
||||
class="uk-width-1-2"
|
||||
thing="smart_scan"
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
Show Stitched Scan
|
||||
</button>
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<div v-if="itemData.card_type === 'Scan'" class="item-info">
|
||||
<ul>
|
||||
<li>{{ itemData.number_of_images }} images</li>
|
||||
<li>Created: {{ formatDate(itemData.created) }}</li>
|
||||
|
|
@ -107,7 +107,17 @@ export default {
|
|||
return `${this.baseUri}/smart_scan/get_stitch/${this.itemData.name}`;
|
||||
},
|
||||
thumbnailPath() {
|
||||
if (this.itemData.card_type === "Scan") {
|
||||
return `${this.baseUri}/smart_scan/scans/stitched_thumbnail.jpg?scan_name=${this.itemData.name}&modified=${this.itemData.modified}`;
|
||||
}
|
||||
return `${this.baseUri}/data/${this.itemData.thing}/${this.itemData.name}`;
|
||||
},
|
||||
/**
|
||||
* Return True if there is a viewer available for this card's data.
|
||||
*/
|
||||
viewerAvailable() {
|
||||
if (this.itemData.card_type === "Scan") return Boolean(this.itemData?.dzi);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -144,7 +154,13 @@ export default {
|
|||
async deleteScan() {
|
||||
try {
|
||||
await this.modalConfirm(`Are you sure you want to delete ${this.itemData.name}?`);
|
||||
if (this.itemData.card_type === "Scan") {
|
||||
await axios.delete(`${this.baseUri}/smart_scan/scans/${this.itemData.name}`);
|
||||
} else {
|
||||
await axios.delete(
|
||||
`${this.baseUri}/${this.itemData.thing}/capture/${this.itemData.name}`,
|
||||
);
|
||||
}
|
||||
this.$emit("update-requested");
|
||||
this.modalNotify(`Deleted ${this.itemData.name}`);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<div id="scan-modal" ref="scanModal" uk-modal>
|
||||
<div v-if="selectedScan" id="scan-modal-body" class="uk-modal-dialog uk-modal-body">
|
||||
<h2 id="scan-modal-title" class="uk-modal-title">
|
||||
{{ selectedScan.name }}
|
||||
<div id="viewer-modal" ref="viewerModal" uk-modal>
|
||||
<div v-if="selectedItem" id="viewer-modal-body" class="uk-modal-dialog uk-modal-body">
|
||||
<h2 id="viewer-modal-title" class="uk-modal-title">
|
||||
{{ selectedItem.name }}
|
||||
<button class="uk-modal-close uk-float-right" type="button">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
|
|
@ -12,11 +12,11 @@
|
|||
</h2>
|
||||
|
||||
<!-- Viewer -->
|
||||
<div v-if="selectedScanDZI" id="viewer_container" class="viewer_container">
|
||||
<div v-if="imageSource" id="viewer_container" class="viewer_container">
|
||||
<OpenSeadragonViewer
|
||||
id="openseadragon"
|
||||
ref="openseadragon"
|
||||
:src="selectedScanDZI"
|
||||
:src="imageSource"
|
||||
:brightness="brightness"
|
||||
:contrast="contrast"
|
||||
:saturation="saturation"
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div v-if="selectedScanDZI" class="viewer-controls">
|
||||
<div v-if="imageSource" class="viewer-controls">
|
||||
<div class="controlsContainer">
|
||||
<label>
|
||||
Brightness
|
||||
|
|
@ -58,12 +58,12 @@ import UIkit from "uikit";
|
|||
import OpenSeadragonViewer from "./openSeadragonViewer.vue";
|
||||
|
||||
export default {
|
||||
name: "ScanViewerModal",
|
||||
name: "GalleryModal",
|
||||
components: {
|
||||
OpenSeadragonViewer,
|
||||
},
|
||||
props: {
|
||||
selectedScan: {
|
||||
selectedItem: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
|
|
@ -79,15 +79,20 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
selectedScanDZI() {
|
||||
if (this.selectedScan && this.selectedScan.dzi) {
|
||||
return `${this.baseUri}/data/smart_scan/${this.selectedScan.name}/images/${this.selectedScan.dzi}`;
|
||||
imageSource() {
|
||||
if (this.selectedItem?.card_type === "Scan" && this.selectedItem?.dzi) {
|
||||
return `${this.baseUri}/data/smart_scan/${this.selectedItem.name}/images/${this.selectedItem.dzi}`;
|
||||
} else if (this.selectedItem?.card_type === "Capture") {
|
||||
return {
|
||||
type: "image",
|
||||
url: `${this.baseUri}/data/${this.selectedItem.thing}/${this.selectedItem.name}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.modalEl = this.$refs.scanModal;
|
||||
this.modalEl = this.$refs.viewerModal;
|
||||
this.beforeHideHandler = (event) => {
|
||||
if (this.enteringFullscreen) {
|
||||
event.preventDefault();
|
||||
|
|
@ -103,10 +108,10 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
show() {
|
||||
UIkit.modal(this.$refs.scanModal).show();
|
||||
UIkit.modal(this.$refs.viewerModal).show();
|
||||
},
|
||||
hide() {
|
||||
UIkit.modal(this.$refs.scanModal).hide();
|
||||
UIkit.modal(this.$refs.viewerModal).hide();
|
||||
},
|
||||
goFullscreen() {
|
||||
this.$refs.openseadragon.openFullscreen();
|
||||
|
|
@ -126,11 +131,11 @@ input[type="range"] {
|
|||
z-index: 1001;
|
||||
}
|
||||
|
||||
#scan-modal {
|
||||
#viewer-modal {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#scan-modal-body {
|
||||
#viewer-modal-body {
|
||||
padding: 10px;
|
||||
width: 95%;
|
||||
height: 95%;
|
||||
|
|
@ -138,7 +143,7 @@ input[type="range"] {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
#scan-modal-title {
|
||||
#viewer-modal-title {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ export default {
|
|||
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
type: [String, Object],
|
||||
required: true,
|
||||
},
|
||||
brightness: {
|
||||
|
|
|
|||
|
|
@ -19,14 +19,19 @@
|
|||
@error="modalError"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="uk-button uk-button-default uk-width-1-1 gallery-button"
|
||||
type="button"
|
||||
@click="deleteAllScans()"
|
||||
>
|
||||
Delete All Scans
|
||||
</button>
|
||||
<div class="gallery-button">
|
||||
<action-button
|
||||
class="uk-width-1-1"
|
||||
thing="gallery"
|
||||
action="delete_all_data"
|
||||
submit-label="Delete All"
|
||||
:can-terminate="true"
|
||||
:button-primary="false"
|
||||
:modal-progress="true"
|
||||
:requires-confirmation="true"
|
||||
:confirmation-message="'<p>Are you sure you want to delete all gallery data from the microscope?</p><p>This is <b>irreversible</b>!</p>'"
|
||||
@error="modalError"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
|
|
@ -41,7 +46,7 @@
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
<ScanViewerModal ref="scanViewer" :selected-scan="selectedScan" :base-uri="baseUri" />
|
||||
<gallery-modal ref="viewerModal" :selected-item="selectedItem" :base-uri="baseUri" />
|
||||
|
||||
<!-- Gallery -->
|
||||
<div v-if="ready" class="uk-padding-remove-top" uk-lightbox="toggle: .lightbox-link">
|
||||
|
|
@ -54,7 +59,7 @@
|
|||
<div v-for="itemData in paginatedItems" :key="itemData.id">
|
||||
<gallery-card
|
||||
:item-data="itemData"
|
||||
@viewer-requested="showScan"
|
||||
@viewer-requested="showItem"
|
||||
@update-requested="refreshGallery"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -69,11 +74,10 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import PaginateLinks from "@/components/genericComponents/paginateLinks.vue";
|
||||
import actionButton from "../labThingsComponents/actionButton.vue";
|
||||
import galleryCard from "./galleryComponents/galleryCard.vue";
|
||||
import ScanViewerModal from "./galleryComponents/scanViewer.vue";
|
||||
import galleryModal from "./galleryComponents/galleryViewer.vue";
|
||||
import { eventBus } from "../../eventBus.js";
|
||||
import { useIntersectionObserver } from "@vueuse/core";
|
||||
import { useSettingsStore } from "@/stores/settings.js";
|
||||
|
|
@ -86,7 +90,7 @@ export default {
|
|||
components: {
|
||||
actionButton,
|
||||
galleryCard,
|
||||
ScanViewerModal,
|
||||
galleryModal,
|
||||
PaginateLinks,
|
||||
},
|
||||
|
||||
|
|
@ -95,7 +99,7 @@ export default {
|
|||
data: function () {
|
||||
return {
|
||||
all_items: [],
|
||||
selectedScan: null,
|
||||
selectedItem: null,
|
||||
osdViewer: null,
|
||||
currentPage: 1,
|
||||
itemsPerPage: 18,
|
||||
|
|
@ -104,21 +108,9 @@ export default {
|
|||
|
||||
computed: {
|
||||
...mapState(useSettingsStore, ["baseUri", "ready"]),
|
||||
scansUri() {
|
||||
// The scans URI is currently used for creating endpoint URIs.
|
||||
// The actual property does not exist. So allowUndefined=true
|
||||
return this.thingPropertyUrl("smart_scan", "scans", true);
|
||||
},
|
||||
noItems() {
|
||||
return !this.all_items || this.all_items?.length === 0;
|
||||
},
|
||||
selectedScanDZI() {
|
||||
if (this.selectedScan && this.selectedScan.dzi != "") {
|
||||
return `${this.baseUri}/data/smart_scan/${this.selectedScan.name}/images/${this.selectedScan.dzi}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
totalPages() {
|
||||
return Math.ceil((this.all_items?.length || 0) / this.itemsPerPage);
|
||||
},
|
||||
|
|
@ -195,27 +187,18 @@ export default {
|
|||
this.all_items = [];
|
||||
}
|
||||
},
|
||||
async deleteAllScans() {
|
||||
try {
|
||||
await this.modalConfirm(
|
||||
"Are you sure you want to delete all scans from the microscope? " +
|
||||
"This is <b>irreversible</b>!",
|
||||
);
|
||||
await axios.delete(`${this.scansUri}`);
|
||||
await this.refreshGallery();
|
||||
this.modalNotify("Deleted all scans.");
|
||||
} catch (e) {
|
||||
// if the confirmation was cancelled, it's rejected with null error
|
||||
if (e) this.modalError(e);
|
||||
}
|
||||
},
|
||||
showScan(scan) {
|
||||
if (scan.dzi) {
|
||||
this.selectedScan = scan;
|
||||
this.$refs.scanViewer.show();
|
||||
showItem(itemData) {
|
||||
if (itemData.card_type === "Scan") {
|
||||
if (itemData.dzi) {
|
||||
this.selectedItem = itemData;
|
||||
this.$refs.viewerModal.show();
|
||||
} else {
|
||||
this.modalError("Scan not stitched for viewing in webapp, please download or stitch");
|
||||
}
|
||||
} else {
|
||||
this.selectedItem = itemData;
|
||||
this.$refs.viewerModal.show();
|
||||
}
|
||||
},
|
||||
changePage(page) {
|
||||
if (page >= 1 && page <= this.totalPages && this.currentPage != page) {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ export const componentOverrides = {
|
|||
"matrixDisplay.vue": {
|
||||
props: { matrix: [] },
|
||||
},
|
||||
"toggleSwitch.vue": {
|
||||
props: { modelValue: true },
|
||||
},
|
||||
"actionProgressBar.vue": {
|
||||
props: { taskStatus: "" },
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue