From c3729eaad8c3d0d1a66568dd58dd8e1d95d521ff Mon Sep 17 00:00:00 2001 From: frcroth Date: Tue, 3 Dec 2024 13:19:21 +0100 Subject: [PATCH 1/6] Warn user when using precomputed meshes after brushing (#8218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add key in volumetracing to store if volume bucket data has changed * show warning in context menu for loading precomputed meshes when bucket data has changed * avoid unnecessary rerenders in context menu * ensure SET_VOLUME_BUCKET_DATA_HAS_CHANGED is dispatched when buckets are changed * add missing file * fix linting * rename load_mesh_menu_item_label file * use action creator instead of hardcoding action * fix that volumeBucketDataHashChanged was not handled when the server sent it * add comments * update snapshots * add changelog entry * remove unintentional changelog entry * change action property from layerName to tracingId --------- Co-authored-by: Philipp Otto Co-authored-by: Philipp Otto Co-authored-by: Michael Büßemeyer --- app/models/annotation/AnnotationService.scala | 3 +- .../model/actions/volumetracing_actions.ts | 10 +++++ .../model/reducers/volumetracing_reducer.ts | 7 ++++ .../oxalis/model/sagas/quick_select_saga.ts | 8 ++-- .../oxalis/model/sagas/saga_helpers.ts | 34 +++++++++++++++- .../sagas/volume/volume_interpolation_saga.ts | 9 +++-- .../oxalis/model/sagas/volumetracing_saga.tsx | 15 +++---- frontend/javascripts/oxalis/store.ts | 1 + .../javascripts/oxalis/view/context_menu.tsx | 13 ++++-- .../load_mesh_menu_item_label.tsx | 37 ++++++++++++++++++ .../segments_tab/segment_list_item.tsx | 18 ++++----- .../volumetracing/volumetracing_saga.spec.ts | 9 +++-- .../annotations.e2e.js.md | 2 + .../annotations.e2e.js.snap | Bin 16824 -> 16858 bytes frontend/javascripts/types/api_flow_types.ts | 4 ++ .../proto/VolumeTracing.proto | 1 + .../volume/VolumeTracingService.scala | 4 +- 17 files changed, 140 insertions(+), 35 deletions(-) create mode 100644 frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/load_mesh_menu_item_label.tsx diff --git a/app/models/annotation/AnnotationService.scala b/app/models/annotation/AnnotationService.scala index 59233b08435..3b333d7bf78 100755 --- a/app/models/annotation/AnnotationService.scala +++ b/app/models/annotation/AnnotationService.scala @@ -179,7 +179,8 @@ class AnnotationService @Inject()( mappingName = mappingName, mags = magsRestricted.map(vec3IntToProto), hasSegmentIndex = Some(fallbackLayer.isEmpty || fallbackLayerHasSegmentIndex), - additionalAxes = AdditionalAxis.toProto(additionalAxes) + additionalAxes = AdditionalAxis.toProto(additionalAxes), + volumeBucketDataHasChanged = Some(false) ) } diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index 781ac413557..caf749eeb3e 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -45,6 +45,9 @@ export type SetSegmentGroupsAction = ReturnType; export type SetExpandedSegmentGroupsAction = ReturnType; export type SetHasEditableMappingAction = ReturnType; export type SetMappingIsLockedAction = ReturnType; +export type SetVolumeBucketDataHasChangedAction = ReturnType< + typeof setVolumeBucketDataHasChangedAction +>; export type ComputeQuickSelectForRectAction = ReturnType; export type ComputeQuickSelectForPointAction = ReturnType; @@ -99,6 +102,7 @@ export type VolumeTracingAction = | FineTuneQuickSelectAction | CancelQuickSelectAction | ConfirmQuickSelectAction + | SetVolumeBucketDataHasChangedAction | BatchUpdateGroupsAndSegmentsAction; export const VolumeTracingSaveRelevantActions = [ @@ -437,3 +441,9 @@ export const batchUpdateGroupsAndSegmentsAction = (actions: BatchableUpdateSegme export const cancelQuickSelectAction = () => ({ type: "CANCEL_QUICK_SELECT" }) as const; export const confirmQuickSelectAction = () => ({ type: "CONFIRM_QUICK_SELECT" }) as const; + +export const setVolumeBucketDataHasChangedAction = (tracingId: string) => + ({ + type: "SET_VOLUME_BUCKET_DATA_HAS_CHANGED", + tracingId, + }) as const; diff --git a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts index e7ffa4f8da8..ec4ea3835af 100644 --- a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts @@ -269,6 +269,7 @@ export function serverVolumeToClientVolumeTracing(tracing: ServerVolumeTracing): mappingName: tracing.mappingName, hasEditableMapping: tracing.hasEditableMapping, mappingIsLocked: tracing.mappingIsLocked, + volumeBucketDataHasChanged: tracing.volumeBucketDataHasChanged, hasSegmentIndex: tracing.hasSegmentIndex || false, additionalAxes: convertServerAdditionalAxesToFrontEnd(tracing.additionalAxes), }; @@ -377,6 +378,12 @@ function VolumeTracingReducer( return expandSegmentParents(state, action); } + case "SET_VOLUME_BUCKET_DATA_HAS_CHANGED": { + return updateVolumeTracing(state, action.tracingId, { + volumeBucketDataHasChanged: true, + }); + } + default: // pass } diff --git a/frontend/javascripts/oxalis/model/sagas/quick_select_saga.ts b/frontend/javascripts/oxalis/model/sagas/quick_select_saga.ts index d863505aed4..791d51e9211 100644 --- a/frontend/javascripts/oxalis/model/sagas/quick_select_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/quick_select_saga.ts @@ -15,7 +15,7 @@ import performQuickSelectML from "./quick_select_ml_saga"; import getSceneController from "oxalis/controller/scene_controller_provider"; import { getActiveSegmentationTracing } from "../accessors/volumetracing_accessor"; import type { VolumeTracing } from "oxalis/store"; -import { ensureMaybeActiveMappingIsLocked } from "./saga_helpers"; +import { requestBucketModificationInVolumeTracing } from "./saga_helpers"; function* shouldUseHeuristic() { const useHeuristic = yield* select((state) => state.userConfiguration.quickSelect.useHeuristic); @@ -32,11 +32,11 @@ export default function* listenToQuickSelect(): Saga { ); if (volumeTracing) { // As changes to the volume layer will be applied, the potentially existing mapping should be locked to ensure a consistent state. - const { isMappingLockedIfNeeded } = yield* call( - ensureMaybeActiveMappingIsLocked, + const isModificationAllowed = yield* call( + requestBucketModificationInVolumeTracing, volumeTracing, ); - if (!isMappingLockedIfNeeded) { + if (!isModificationAllowed) { return; } } diff --git a/frontend/javascripts/oxalis/model/sagas/saga_helpers.ts b/frontend/javascripts/oxalis/model/sagas/saga_helpers.ts index 2ebc695b159..abf7b049b1e 100644 --- a/frontend/javascripts/oxalis/model/sagas/saga_helpers.ts +++ b/frontend/javascripts/oxalis/model/sagas/saga_helpers.ts @@ -9,7 +9,10 @@ import { call, put, takeEvery } from "typed-redux-saga"; import Toast from "libs/toast"; import { Store } from "oxalis/singletons"; import type { ActionPattern } from "@redux-saga/types"; -import { setMappingIsLockedAction } from "../actions/volumetracing_actions"; +import { + setMappingIsLockedAction, + setVolumeBucketDataHasChangedAction, +} from "../actions/volumetracing_actions"; import { MappingStatusEnum } from "oxalis/constants"; export function* takeEveryUnlessBusy

( @@ -113,4 +116,33 @@ export function* ensureMaybeActiveMappingIsLocked( } } +export function* requestBucketModificationInVolumeTracing( + volumeTracing: VolumeTracing, +): Saga { + /* + * Should be called when the modification of bucket data is about to happen. If + * the saga returns false, the modification should be cancelled (this happens if + * the user is not okay with locking the mapping). + * + * In detail: When the bucket data of a volume tracing is supposed to be mutated, we need to do + * two things: + * 1) ensure that the current mapping (or no mapping) is locked so that the mapping is not + * changed later (this would lead to inconsistent data). If the mapping state is not yet + * locked, the user is asked whether it is okay to lock it. + * If the user confirms this, the mapping is locked and we can continue with (2). If the + * user denies the locking request, the original bucket mutation will NOT be executed. + * 2) volumeTracing.volumeBucketDataHasChanged will bet set to true if the user didn't + * deny the request for locking the mapping. + */ + + const { isMappingLockedIfNeeded } = yield* call(ensureMaybeActiveMappingIsLocked, volumeTracing); + if (!isMappingLockedIfNeeded) { + return false; + } + + // Mark that bucket data has changed + yield* put(setVolumeBucketDataHasChangedAction(volumeTracing.tracingId)); + return true; +} + export default {}; diff --git a/frontend/javascripts/oxalis/model/sagas/volume/volume_interpolation_saga.ts b/frontend/javascripts/oxalis/model/sagas/volume/volume_interpolation_saga.ts index 55f47e57724..3d1690b9363 100644 --- a/frontend/javascripts/oxalis/model/sagas/volume/volume_interpolation_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/volume/volume_interpolation_saga.ts @@ -38,7 +38,7 @@ import { Model, api } from "oxalis/singletons"; import type { OxalisState } from "oxalis/store"; import { call, put } from "typed-redux-saga"; import { createVolumeLayer, getBoundingBoxForViewport, labelWithVoxelBuffer2D } from "./helpers"; -import { ensureMaybeActiveMappingIsLocked } from "../saga_helpers"; +import { requestBucketModificationInVolumeTracing } from "../saga_helpers"; /* * This saga is capable of doing segment interpolation between two slices. @@ -436,8 +436,11 @@ export default function* maybeInterpolateSegmentationLayer(): Saga { return; } // As the interpolation will be applied, the potentially existing mapping should be locked to ensure a consistent state. - const { isMappingLockedIfNeeded } = yield* call(ensureMaybeActiveMappingIsLocked, volumeTracing); - if (!isMappingLockedIfNeeded) { + const isModificationAllowed = yield* call( + requestBucketModificationInVolumeTracing, + volumeTracing, + ); + if (!isModificationAllowed) { return; } diff --git a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx index f1770e8598c..12e1919ac31 100644 --- a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx @@ -82,7 +82,7 @@ import { select, take } from "oxalis/model/sagas/effect-generators"; import listenToMinCut from "oxalis/model/sagas/min_cut_saga"; import listenToQuickSelect from "oxalis/model/sagas/quick_select_saga"; import { - ensureMaybeActiveMappingIsLocked, + requestBucketModificationInVolumeTracing, takeEveryUnlessBusy, } from "oxalis/model/sagas/saga_helpers"; import { @@ -223,11 +223,11 @@ export function* editVolumeLayerAsync(): Saga { const activeCellId = yield* select((state) => enforceActiveVolumeTracing(state).activeCellId); // As changes to the volume layer will be applied, the potentially existing mapping should be locked to ensure a consistent state. - const { isMappingLockedIfNeeded } = yield* call( - ensureMaybeActiveMappingIsLocked, + const isModificationAllowed = yield* call( + requestBucketModificationInVolumeTracing, volumeTracing, ); - if (!isMappingLockedIfNeeded) { + if (!isModificationAllowed) { continue; } @@ -453,11 +453,11 @@ export function* floodFill(): Saga { } // As the flood fill will be applied to the volume layer, // the potentially existing mapping should be locked to ensure a consistent state. - const { isMappingLockedIfNeeded } = yield* call( - ensureMaybeActiveMappingIsLocked, + const isModificationAllowed = yield* call( + requestBucketModificationInVolumeTracing, volumeTracing, ); - if (!isMappingLockedIfNeeded) { + if (!isModificationAllowed) { continue; } yield* put(setBusyBlockingInfoAction(true, "Floodfill is being computed.")); @@ -603,6 +603,7 @@ export function* finishLayer( yield* put(registerLabelPointAction(layer.getUnzoomedCentroid())); } + export function* ensureToolIsAllowedInMag(): Saga { yield* take("INITIALIZE_VOLUMETRACING"); diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index deca802926a..26fb97c0dd0 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -264,6 +264,7 @@ export type VolumeTracing = TracingBase & { readonly hasEditableMapping?: boolean; readonly mappingIsLocked?: boolean; readonly hasSegmentIndex: boolean; + readonly volumeBucketDataHasChanged?: boolean; }; export type ReadOnlyTracing = TracingBase & { readonly type: "readonly"; diff --git a/frontend/javascripts/oxalis/view/context_menu.tsx b/frontend/javascripts/oxalis/view/context_menu.tsx index 21ddce3d0ca..634de6a7c59 100644 --- a/frontend/javascripts/oxalis/view/context_menu.tsx +++ b/frontend/javascripts/oxalis/view/context_menu.tsx @@ -131,6 +131,7 @@ import { import { hideContextMenuAction, setActiveUserBoundingBoxId } from "oxalis/model/actions/ui_actions"; import { getDisabledInfoForTools } from "oxalis/model/accessors/tool_accessor"; import FastTooltip from "components/fast_tooltip"; +import { LoadMeshMenuItemLabel } from "./right-border-tabs/segments_tab/load_mesh_menu_item_label"; type ContextMenuContextValue = React.MutableRefObject | null; export const ContextMenuContext = createContext(null); @@ -1179,7 +1180,9 @@ function getNoNodeContextMenuOptions(props: NoNodeContextMenuProps): ItemType[] segmentationLayerName, mappingInfo, ), - label: "Load Mesh (precomputed)", + label: ( + + ), }; const computeMeshAdHocItem = { key: "compute-mesh-adhc", @@ -1434,12 +1437,13 @@ function ContextMenuInner(propsWithInputRef: Props) { maybeClickedMeshId != null ? maybeClickedMeshId : segmentIdAtPosition; const wasSegmentOrMeshClicked = clickedSegmentOrMeshId > 0; - const { dataset, tracing, flycam } = useSelector((state: OxalisState) => state); + const dataset = useSelector((state: OxalisState) => state.dataset); useEffect(() => { Store.dispatch(ensureSegmentIndexIsLoadedAction(visibleSegmentationLayer?.name)); }, [visibleSegmentationLayer]); - const isSegmentIndexAvailable = useSelector((state: OxalisState) => - getMaybeSegmentIndexAvailability(state.dataset, visibleSegmentationLayer?.name), + const isSegmentIndexAvailable = getMaybeSegmentIndexAvailability( + dataset, + visibleSegmentationLayer?.name, ); const mappingName: string | null | undefined = useSelector((state: OxalisState) => { if (volumeTracing?.mappingName != null) return volumeTracing?.mappingName; @@ -1453,6 +1457,7 @@ function ContextMenuInner(propsWithInputRef: Props) { const isLoadingVolumeAndBB = [isLoadingMessage, isLoadingMessage]; const [segmentVolumeLabel, boundingBoxInfoLabel] = useFetch( async () => { + const { tracing, flycam } = Store.getState(); // The value that is returned if the context menu is closed is shown if it's still loading if (contextMenuPosition == null || !wasSegmentOrMeshClicked) return isLoadingVolumeAndBB; if (visibleSegmentationLayer == null || !isSegmentIndexAvailable) return []; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/load_mesh_menu_item_label.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/load_mesh_menu_item_label.tsx new file mode 100644 index 00000000000..0faa65c529f --- /dev/null +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/load_mesh_menu_item_label.tsx @@ -0,0 +1,37 @@ +import { WarningOutlined } from "@ant-design/icons"; +import FastTooltip from "components/fast_tooltip"; +import type { VolumeTracing } from "oxalis/store"; +import type { APIMeshFile } from "types/api_flow_types"; + +type Props = { + currentMeshFile: APIMeshFile | null | undefined; + volumeTracing: VolumeTracing | null | undefined; +}; + +export function LoadMeshMenuItemLabel({ currentMeshFile, volumeTracing }: Props) { + const showWarning = + volumeTracing?.volumeBucketDataHasChanged ?? + // For older annotations, volumeBucketDataHasChanged can be undefined. + // In that case, we still want to show a warning if no proofreading was + // done, but the mapping is still locked (i.e., the user brushed). + (!volumeTracing?.hasEditableMapping && volumeTracing?.mappingIsLocked); + + return ( + + + Load Mesh (precomputed) + + {showWarning && ( + + + + )} + + ); +} diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx index dd4ea4ac386..04ba5ef6737 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx @@ -40,6 +40,7 @@ import { V4 } from "libs/mjs"; import { ChangeColorMenuItemContent } from "components/color_picker"; import type { MenuItemType } from "antd/es/menu/interface"; import { withMappingActivationConfirmation } from "./segments_view_helper"; +import { LoadMeshMenuItemLabel } from "./load_mesh_menu_item_label"; import type { AdditionalCoordinate } from "types/api_flow_types"; import { getAdditionalCoordinatesAsString, @@ -80,8 +81,10 @@ const getLoadPrecomputedMeshMenuItem = ( hideContextMenu: (_ignore?: any) => void, layerName: string | null | undefined, mappingInfo: ActiveMappingInfo, + activeVolumeTracing: VolumeTracing | null | undefined, ) => { const mappingName = currentMeshFile != null ? currentMeshFile.mappingName : undefined; + return { key: "loadPrecomputedMesh", disabled: !currentMeshFile, @@ -114,16 +117,10 @@ const getLoadPrecomputedMeshMenuItem = ( mappingInfo, ), label: ( - - Load Mesh (precomputed) - + ), }; }; @@ -429,6 +426,7 @@ function _SegmentListItem({ hideContextMenu, visibleSegmentationLayer != null ? visibleSegmentationLayer.name : null, mappingInfo, + activeVolumeTracing, ), getComputeMeshAdHocMenuItem( segment, diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts index 34bfd2c302b..206b45c64d3 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts @@ -40,7 +40,10 @@ mockRequire("libs/toast", { const { setupSavingForTracingType } = require("oxalis/model/sagas/save_saga"); const { editVolumeLayerAsync, finishLayer } = require("oxalis/model/sagas/volumetracing_saga"); -const { ensureMaybeActiveMappingIsLocked } = require("oxalis/model/sagas/saga_helpers"); +const { + requestBucketModificationInVolumeTracing, + ensureMaybeActiveMappingIsLocked, +} = require("oxalis/model/sagas/saga_helpers"); const VolumeLayer = require("oxalis/model/volumetracing/volumelayer").default; @@ -437,11 +440,11 @@ test("VolumeTracingSaga should lock an active mapping upon first volume annotati mag: [1, 1, 1], zoomStep: 0, }); - // Test whether nested saga ensureMaybeActiveMappingIsLocked is called. + // Test whether nested saga requestBucketModificationInVolumeTracing is called. expectValueDeepEqual( t, saga.next(ACTIVE_CELL_ID), - call(ensureMaybeActiveMappingIsLocked, volumeTracing), + call(requestBucketModificationInVolumeTracing, volumeTracing), ); }); diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md index 222a9ed027d..48087c9ed20 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md @@ -1318,6 +1318,7 @@ Generated by [AVA](https://avajs.dev). typ: 'Volume', userBoundingBoxes: [], version: 0, + volumeBucketDataHasChanged: false, zoomLevel: 1, } @@ -1408,6 +1409,7 @@ Generated by [AVA](https://avajs.dev). typ: 'Volume', userBoundingBoxes: [], version: 0, + volumeBucketDataHasChanged: false, zoomLevel: 1, }, } diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap index 818cad4f78548902018823349f8c01c8015e3f9b..8a0b4fd925d493beaebeb156e2226ecc6b5a7516 100644 GIT binary patch literal 16858 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 b|IY5~2>LpLzK)>xbp-zhO9m>j_r(DK2^6=Z literal 16824 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 diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index e3fb3502ed8..958651b32ec 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -875,6 +875,10 @@ export type ServerVolumeTracing = ServerTracingBase & { hasEditableMapping?: boolean; mappingIsLocked?: boolean; hasSegmentIndex?: boolean; + // volumeBucketDataHasChanged is automatically set to true by the back-end + // once a bucket was mutated. There is no need to send an explicit UpdateAction + // for that. + volumeBucketDataHasChanged?: boolean; }; export type ServerTracing = ServerSkeletonTracing | ServerVolumeTracing; export type ServerEditableMapping = { diff --git a/webknossos-datastore/proto/VolumeTracing.proto b/webknossos-datastore/proto/VolumeTracing.proto index 0f06affd9ad..495d1a481ff 100644 --- a/webknossos-datastore/proto/VolumeTracing.proto +++ b/webknossos-datastore/proto/VolumeTracing.proto @@ -48,6 +48,7 @@ message VolumeTracing { repeated AdditionalCoordinateProto editPositionAdditionalCoordinates = 21; repeated AdditionalAxisProto additionalAxes = 22; // Additional axes for which this tracing is defined optional bool mappingIsLocked = 23; // user may not select another mapping (e.g. because they have already mutated buckets) + optional bool volumeBucketDataHasChanged = 24; // The volume bucket data has been edited at least once } message SegmentGroup { diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala index 5dfe5bb6723..f78c53cd0b8 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala @@ -210,7 +210,7 @@ class VolumeTracingService @Inject()( ) ?~> "failed to update segment index" } yield () } - } yield volumeTracing + } yield volumeTracing.copy(volumeBucketDataHasChanged = Some(true)) override def editableMappingTracingId(tracing: VolumeTracing, tracingId: String): Option[String] = if (tracing.getHasEditableMapping) Some(tracingId) else None @@ -281,7 +281,7 @@ class VolumeTracingService @Inject()( } yield () })) _ <- segmentIndexBuffer.flush() - } yield volumeTracing + } yield volumeTracing.copy(volumeBucketDataHasChanged = Some(true)) private def assertMagIsValid(tracing: VolumeTracing, mag: Vec3Int): Fox[Unit] = if (tracing.mags.nonEmpty) { From 2781061580a6a21c435a4f6213f493ac109d62e4 Mon Sep 17 00:00:00 2001 From: robert-oleynik <62473688+robert-oleynik@users.noreply.github.com> Date: Wed, 4 Dec 2024 09:01:47 +0100 Subject: [PATCH 2/6] Fix `FATAL: role "postgres" does not exists` for docker compose deployment (#8240) * update pg_isready args to database configuration * update changelog --- CHANGELOG.unreleased.md | 1 + docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 8a3c9a0f3d1..752cda32872 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -36,6 +36,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Fixed a bug where trying to delete a non-existing node (via the API, for example) would delete the whole active tree. [#8176](https://github.com/scalableminds/webknossos/pull/8176) - Fixed a bug where dataset uploads would fail if the organization directory on disk is missing. [#8230](https://github.com/scalableminds/webknossos/pull/8230) - Fixed some layout issues in the upload view. [#8231](https://github.com/scalableminds/webknossos/pull/8231) +- Fixed `FATAL: role "postgres" does not exist` error message in Docker compose. [#8240](https://github.com/scalableminds/webknossos/pull/8240) ### Removed - Removed support for HTTP API versions 3 and 4. [#8075](https://github.com/scalableminds/webknossos/pull/8075) diff --git a/docker-compose.yml b/docker-compose.yml index a1f0e549c0e..879f777e4f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -222,7 +222,7 @@ services: POSTGRES_USER: webknossos_user POSTGRES_PASSWORD: secret_password healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -h 127.0.0.1 -p 5432"] + test: ["CMD-SHELL", "pg_isready -d webknossos -U webknossos_user -h 127.0.0.1 -p 5432"] interval: 2s timeout: 5s retries: 30 From 9a5c8e82b10dc9e4738d669bf7a33da0b5dc04f4 Mon Sep 17 00:00:00 2001 From: Tom Herold Date: Wed, 4 Dec 2024 09:35:42 +0100 Subject: [PATCH 3/6] Fix image in docs --- CHANGELOG.released.md | 2 +- docs/users/new_users.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.released.md b/CHANGELOG.released.md index 0dd98179efd..85470e55ef7 100644 --- a/CHANGELOG.released.md +++ b/CHANGELOG.released.md @@ -451,7 +451,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Fixed - Fixed that some segment (group) actions were not properly disabled for non-editable segmentation layers. [#7207](https://github.com/scalableminds/webknossos/issues/7207) - Fixed a bug where data from zarr2 datasets that have a channel axis was broken. [#7374](https://github.com/scalableminds/webknossos/pull/7374) -- Fixed a bug which changed the cursor position while editing the name of a tree or the comment of a node. [#7390](#https://github.com/scalableminds/webknossos/pull/7390) +- Fixed a bug which changed the cursor position while editing the name of a tree or the comment of a node. [#7390](https://github.com/scalableminds/webknossos/pull/7390) - Streaming sharded zarr3 datasets from servers which do not respond with Accept-Ranges header is now possible. [#7392](https://github.com/scalableminds/webknossos/pull/7392) ## [23.10.2](https://github.com/scalableminds/webknossos/releases/tag/23.10.2) - 2023-09-26 diff --git a/docs/users/new_users.md b/docs/users/new_users.md index a9d38485761..23d5dba6381 100644 --- a/docs/users/new_users.md +++ b/docs/users/new_users.md @@ -3,7 +3,7 @@ On webknossos.org, users can either sign up for a WEBKNOSSOS account by themselv As an admin or team manager, you can invite users to join your WEBKNOSSOS organization by clicking the `Invite Users` button at the top of the `Users` list available from the `Admin` menu in the navbar. This will open a popup where you can enter a list of email addresses, which will receive a custom invitation link. Users that click on this link are automatically assigned to your organization, and will not need manual activation. - ![Send an invite link to new users](../images/users_invite.jpeg) +![Send an invite link to new users](../images/users_invite.jpeg) ## Experience Levels For a fine-grained assignment to [annotation tasks](../tasks_projects/tasks.md), each user can have one or more experience levels assigned to them. Based on their respective experience level, tasks may or may not be distributed to them. From 6483aada252cee1ee580aa41e857fdfeb5797f59 Mon Sep 17 00:00:00 2001 From: Florian M Date: Wed, 4 Dec 2024 13:19:54 +0100 Subject: [PATCH 4/6] In NML upload with overwritingDatasetId, do not require valid orga field in the NML (#8258) --- app/models/annotation/nml/NmlParser.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/annotation/nml/NmlParser.scala b/app/models/annotation/nml/NmlParser.scala index e4f06908b31..99a16727784 100755 --- a/app/models/annotation/nml/NmlParser.scala +++ b/app/models/annotation/nml/NmlParser.scala @@ -83,7 +83,7 @@ class NmlParser @Inject()(datasetDAO: DatasetDAO) extends LazyLogging with Proto zoomLevel = nmlParams.zoomLevel, userBoundingBox = None, userBoundingBoxes = nmlParams.userBoundingBoxes, - organizationId = Some(nmlParams.organizationId), + organizationId = Some(dataset._organization), segments = v.segments, mappingName = v.mappingName, mappingIsLocked = v.mappingIsLocked, @@ -113,7 +113,7 @@ class NmlParser @Inject()(datasetDAO: DatasetDAO) extends LazyLogging with Proto None, nmlParams.treeGroupsAfterSplit, nmlParams.userBoundingBoxes, - Some(nmlParams.organizationId), + Some(dataset._organization), nmlParams.editPositionAdditionalCoordinates, additionalAxes = nmlParams.additionalAxisProtos ) From 2ebe1c0ffb884b74ade7e4b3267aba6525b34a68 Mon Sep 17 00:00:00 2001 From: MichaelBuessemeyer <39529669+MichaelBuessemeyer@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:47:48 +0100 Subject: [PATCH 5/6] Fix legacy support for outdated displayName field of datasets (#8263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix legacy support for outdated displayName field of datasets * add changelog entry * remove directoryName from serialized dataset json format in legacy routes --------- Co-authored-by: Michael Büßemeyer --- CHANGELOG.unreleased.md | 1 + app/controllers/DatasetController.scala | 2 +- app/controllers/LegacyApiController.scala | 19 +++++++++++++++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 752cda32872..3abef3313fa 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -32,6 +32,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Fixed a bug where changing the color of a segment via the menu in the segments tab would update the segment color of the previous segment, on which the context menu was opened. [#8225](https://github.com/scalableminds/webknossos/pull/8225) - Fixed a bug where in the add remote dataset view the dataset name setting was not in sync with the datasource setting of the advanced tab making the form not submittable. [#8245](https://github.com/scalableminds/webknossos/pull/8245) - Fixed a bug when importing an NML with groups when only groups but no trees exist in an annotation. [#8176](https://github.com/scalableminds/webknossos/pull/8176) +- Fix read and update dataset route for versions 8 and lower. [#8263](https://github.com/scalableminds/webknossos/pull/8263) - Added missing legacy support for `isValidNewName` route. [#8252](https://github.com/scalableminds/webknossos/pull/8252) - Fixed a bug where trying to delete a non-existing node (via the API, for example) would delete the whole active tree. [#8176](https://github.com/scalableminds/webknossos/pull/8176) - Fixed a bug where dataset uploads would fail if the organization directory on disk is missing. [#8230](https://github.com/scalableminds/webknossos/pull/8230) diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index cf90bdfa10f..277fd359ee2 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -339,7 +339,7 @@ class DatasetController @Inject()(userService: UserService, sil.SecuredAction.async(parse.json) { implicit request => withJsonBodyUsing(datasetPublicReads) { case (description, datasetName, legacyDatasetDisplayName, sortingKey, isPublic, tags, metadata, folderId) => { - val name = if (datasetName.isDefined) datasetName else legacyDatasetDisplayName + val name = if (legacyDatasetDisplayName.isDefined) legacyDatasetDisplayName else datasetName for { datasetIdValidated <- ObjectId.fromString(datasetId) dataset <- datasetDAO.findOne(datasetIdValidated) ?~> notFoundMessage(datasetIdValidated.toString) ~> NOT_FOUND diff --git a/app/controllers/LegacyApiController.scala b/app/controllers/LegacyApiController.scala index e92e686bdc9..cb8d983285f 100644 --- a/app/controllers/LegacyApiController.scala +++ b/app/controllers/LegacyApiController.scala @@ -72,7 +72,8 @@ class LegacyApiController @Inject()(annotationController: AnnotationController, for { dataset <- datasetDAO.findOneByNameAndOrganization(datasetName, organizationId) result <- datasetController.read(dataset._id.toString, sharingToken)(request) - } yield result + adaptedResult <- replaceInResult(migrateDatasetJsonToOldFormat)(result) + } yield adaptedResult } def updateDatasetV8(organizationId: String, datasetName: String): Action[JsValue] = @@ -81,7 +82,8 @@ class LegacyApiController @Inject()(annotationController: AnnotationController, _ <- Fox.successful(logVersioned(request)) dataset <- datasetDAO.findOneByNameAndOrganization(datasetName, organizationId) result <- datasetController.update(dataset._id.toString)(request) - } yield result + adaptedResult <- replaceInResult(migrateDatasetJsonToOldFormat)(result) + } yield adaptedResult } def updateDatasetTeamsV8(organizationId: String, datasetName: String): Action[List[ObjectId]] = @@ -241,6 +243,19 @@ class LegacyApiController @Inject()(annotationController: AnnotationController, /* private helper methods for legacy adaptation */ + private def migrateDatasetJsonToOldFormat(jsResult: JsObject): Fox[JsObject] = { + val datasetName = (jsResult \ "name").asOpt[String] + val directoryName = (jsResult \ "directoryName").asOpt[String] + datasetName.zip(directoryName) match { + case Some((name, dirName)) => + for { + dsWithOldNameField <- tryo(jsResult - "directoryName" + ("name" -> Json.toJson(dirName))).toFox + dsWithOldDisplayNameField <- tryo(dsWithOldNameField + ("displayName" -> Json.toJson(name))).toFox + } yield dsWithOldDisplayNameField + case _ => Fox.successful(jsResult) + } + } + private def addDataSetToTaskInAnnotation(jsResult: JsObject): Fox[JsObject] = { val taskObjectOpt = (jsResult \ "task").asOpt[JsObject] taskObjectOpt From 2bdc9eb7748d09dab64f03f240b9c09ccd4b2b23 Mon Sep 17 00:00:00 2001 From: MichaelBuessemeyer <39529669+MichaelBuessemeyer@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:17:36 +0100 Subject: [PATCH 6/6] Remove debug logging for ds listing (#8256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "Re-Add debug logging for ds listing (#8251)" This reverts commit 0a2afa4b9ad08e03f96ea164fe71e415521215f1. * add some error message * Update app/models/dataset/DataStore.scala Co-authored-by: Florian M --------- Co-authored-by: Michael Büßemeyer Co-authored-by: Florian M --- app/controllers/DatasetController.scala | 105 ++++++++++-------------- app/models/dataset/Dataset.scala | 14 ++-- app/models/dataset/DatasetService.scala | 22 ++--- app/models/user/UserService.scala | 4 +- conf/messages | 4 +- 5 files changed, 63 insertions(+), 86 deletions(-) diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index 277fd359ee2..8a88dcf4b1e 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -176,79 +176,58 @@ class DatasetController @Inject()(userService: UserService, // Change output format to return only a compact list with essential information on the datasets compact: Option[Boolean] ): Action[AnyContent] = sil.UserAwareAction.async { implicit request => - log() { - for { - folderIdValidated <- Fox.runOptional(folderId)(ObjectId.fromString) - uploaderIdValidated <- Fox.runOptional(uploaderId)(ObjectId.fromString) - organizationIdOpt = if (onlyMyOrganization.getOrElse(false)) - request.identity.map(_._organization) - else - organizationId - js <- if (compact.getOrElse(false)) { - for { - datasetInfos <- datasetDAO.findAllCompactWithSearch( - isActive, - isUnreported, - organizationIdOpt, - folderIdValidated, - uploaderIdValidated, - searchQuery, - request.identity.map(_._id), - recursive.getOrElse(false), - limitOpt = limit - ) - } yield Json.toJson(datasetInfos) - } else { - for { - _ <- Fox.successful(()) - _ = logger.info( - s"Requesting listing datasets with isActive '$isActive', isUnreported '$isUnreported', organizationId '$organizationIdOpt', folderId '$folderIdValidated', uploaderId '$uploaderIdValidated', searchQuery '$searchQuery', recursive '$recursive', limit '$limit'") - datasets <- datasetDAO.findAllWithSearch(isActive, - isUnreported, - organizationIdOpt, - folderIdValidated, - uploaderIdValidated, - searchQuery, - recursive.getOrElse(false), - limit) ?~> "dataset.list.failed" ?~> "Dataset listing failed" - _ = logger.info(s"Found ${datasets.size} datasets successfully") - js <- listGrouped(datasets, request.identity) ?~> "dataset.list.failed" ?~> "Grouping datasets failed" - } yield Json.toJson(js) - } - _ = Fox.runOptional(request.identity)(user => userDAO.updateLastActivity(user._id)) - } yield addRemoteOriginHeaders(Ok(js)) - } + for { + folderIdValidated <- Fox.runOptional(folderId)(ObjectId.fromString) + uploaderIdValidated <- Fox.runOptional(uploaderId)(ObjectId.fromString) + organizationIdOpt = if (onlyMyOrganization.getOrElse(false)) + request.identity.map(_._organization) + else + organizationId + js <- if (compact.getOrElse(false)) { + for { + datasetInfos <- datasetDAO.findAllCompactWithSearch( + isActive, + isUnreported, + organizationIdOpt, + folderIdValidated, + uploaderIdValidated, + searchQuery, + request.identity.map(_._id), + recursive.getOrElse(false), + limitOpt = limit + ) + } yield Json.toJson(datasetInfos) + } else { + for { + datasets <- datasetDAO.findAllWithSearch(isActive, + isUnreported, + organizationIdOpt, + folderIdValidated, + uploaderIdValidated, + searchQuery, + recursive.getOrElse(false), + limit) ?~> "dataset.list.failed" + js <- listGrouped(datasets, request.identity) ?~> "dataset.list.grouping.failed" + } yield Json.toJson(js) + } + _ = Fox.runOptional(request.identity)(user => userDAO.updateLastActivity(user._id)) + } yield addRemoteOriginHeaders(Ok(js)) } private def listGrouped(datasets: List[Dataset], requestingUser: Option[User])( implicit ctx: DBAccessContext, m: MessagesProvider): Fox[List[JsObject]] = for { - _ <- Fox.successful(()) - _ = logger.info(s"datasets: $datasets, requestingUser: ${requestingUser.map(_._id)}") requestingUserTeamManagerMemberships <- Fox.runOptional(requestingUser)(user => - userService - .teamManagerMembershipsFor(user._id)) ?~> s"Could not find team manager memberships for user ${requestingUser - .map(_._id)}" - _ = logger.info( - s"requestingUserTeamManagerMemberships: ${requestingUserTeamManagerMemberships.map(_.map(_.toString))}") + userService.teamManagerMembershipsFor(user._id)) groupedByOrga = datasets.groupBy(_._organization).toList js <- Fox.serialCombined(groupedByOrga) { byOrgaTuple: (String, List[Dataset]) => for { - _ <- Fox.successful(()) - _ = logger.info(s"byOrgaTuple orga: ${byOrgaTuple._1}, datasets: ${byOrgaTuple._2}") - organization <- organizationDAO.findOne(byOrgaTuple._1)(GlobalAccessContext) ?~> s"Could not find organization ${byOrgaTuple._1}" + organization <- organizationDAO.findOne(byOrgaTuple._1)(GlobalAccessContext) ?~> "organization.notFound" groupedByDataStore = byOrgaTuple._2.groupBy(_._dataStore).toList - _ <- Fox.serialCombined(groupedByDataStore) { byDataStoreTuple: (String, List[Dataset]) => - { - logger.info(s"datastore: ${byDataStoreTuple._1}, datasets: ${byDataStoreTuple._2}") - Fox.successful(()) - } - } result <- Fox.serialCombined(groupedByDataStore) { byDataStoreTuple: (String, List[Dataset]) => for { - dataStore <- dataStoreDAO.findOneByName(byDataStoreTuple._1.trim)(GlobalAccessContext) ?~> - s"Could not find data store ${byDataStoreTuple._1}" + dataStore <- dataStoreDAO.findOneByName(byDataStoreTuple._1.trim)(GlobalAccessContext) resultByDataStore: Seq[JsObject] <- Fox.serialCombined(byDataStoreTuple._2) { d => datasetService.publicWrites( d, @@ -256,11 +235,11 @@ class DatasetController @Inject()(userService: UserService, Some(organization), Some(dataStore), requestingUserTeamManagerMemberships) ?~> Messages("dataset.list.writesFailed", d.name) - } ?~> "Could not find public writes for datasets" + } } yield resultByDataStore - } ?~> s"Could not group by datastore for datasets ${byOrgaTuple._2.map(_._id)}" + } } yield result.flatten - } ?~> s"Could not group by organization for datasets ${datasets.map(_._id)}" + } } yield js.flatten def accessList(datasetId: String): Action[AnyContent] = sil.SecuredAction.async { implicit request => diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index bb6feade662..ebfa63387a1 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -115,14 +115,12 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA protected def parse(r: DatasetsRow): Fox[Dataset] = for { - voxelSize <- parseVoxelSizeOpt(r.voxelsizefactor, r.voxelsizeunit) ?~> "could not parse dataset voxel size" + voxelSize <- parseVoxelSizeOpt(r.voxelsizefactor, r.voxelsizeunit) defaultViewConfigurationOpt <- Fox.runOptional(r.defaultviewconfiguration)( - JsonHelper - .parseAndValidateJson[DatasetViewConfiguration](_)) ?~> "could not parse dataset default view configuration" + JsonHelper.parseAndValidateJson[DatasetViewConfiguration](_)) adminViewConfigurationOpt <- Fox.runOptional(r.adminviewconfiguration)( - JsonHelper - .parseAndValidateJson[DatasetViewConfiguration](_)) ?~> "could not parse dataset admin view configuration" - metadata <- JsonHelper.parseAndValidateJson[JsArray](r.metadata) ?~> "could not parse dataset metadata" + JsonHelper.parseAndValidateJson[DatasetViewConfiguration](_)) + metadata <- JsonHelper.parseAndValidateJson[JsArray](r.metadata) } yield { Dataset( ObjectId(r._Id), @@ -220,11 +218,9 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA includeSubfolders, None, None) - _ = logger.info(s"Requesting datasets with selection predicates '$selectionPredicates'") limitQuery = limitOpt.map(l => q"LIMIT $l").getOrElse(q"") - _ = logger.info("Requesting datasets with query") r <- run(q"SELECT $columns FROM $existingCollectionName WHERE $selectionPredicates $limitQuery".as[DatasetsRow]) - parsed <- parseAll(r) ?~> "Parsing datasets failed" + parsed <- parseAll(r) } yield parsed def findAllCompactWithSearch(isActiveOpt: Option[Boolean] = None, diff --git a/app/models/dataset/DatasetService.scala b/app/models/dataset/DatasetService.scala index f607fdeb92f..16498011695 100644 --- a/app/models/dataset/DatasetService.scala +++ b/app/models/dataset/DatasetService.scala @@ -349,19 +349,19 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, organizationDAO.findOne(dataset._organization) ?~> "organization.notFound" } dataStore <- Fox.fillOption(dataStore) { - dataStoreFor(dataset) ?~> s"fetching data store failed for dataset ${dataset._id}" + dataStoreFor(dataset) ?~> "dataStore.notFound" } - teams <- teamService.allowedTeamsForDataset(dataset, cumulative = false, requestingUserOpt) ?~> "dataset.list.fetchAllowedTeamsFailed" ?~> s"for dataset ${dataset._id}" - teamsJs <- Fox.serialCombined(teams)(t => teamService.publicWrites(t, Some(organization))) ?~> "dataset.list.teamWritesFailed" ?~> s"for dataset ${dataset._id}" - teamsCumulative <- teamService.allowedTeamsForDataset(dataset, cumulative = true, requestingUserOpt) ?~> "dataset.list.fetchAllowedTeamsFailed" ?~> s"for dataset ${dataset._id}" - teamsCumulativeJs <- Fox.serialCombined(teamsCumulative)(t => teamService.publicWrites(t, Some(organization))) ?~> "dataset.list.teamWritesFailed" ?~> s"for dataset ${dataset._id}" - logoUrl <- logoUrlFor(dataset, Some(organization)) ?~> "dataset.list.fetchLogoUrlFailed" ?~> s"for dataset ${dataset._id}" - isEditable <- isEditableBy(dataset, requestingUserOpt, requestingUserTeamManagerMemberships) ?~> "dataset.list.isEditableCheckFailed" ?~> s"for dataset ${dataset._id}" - lastUsedByUser <- lastUsedTimeFor(dataset._id, requestingUserOpt) ?~> "dataset.list.fetchLastUsedTimeFailed" ?~> s"for dataset ${dataset._id}" - dataStoreJs <- dataStoreService.publicWrites(dataStore) ?~> "dataset.list.dataStoreWritesFailed" ?~> s"for dataset ${dataset._id}" - dataSource <- dataSourceFor(dataset, Some(organization)) ?~> "dataset.list.fetchDataSourceFailed" ?~> s"for dataset ${dataset._id}" + teams <- teamService.allowedTeamsForDataset(dataset, cumulative = false, requestingUserOpt) ?~> "dataset.list.fetchAllowedTeamsFailed" + teamsJs <- Fox.serialCombined(teams)(t => teamService.publicWrites(t, Some(organization))) ?~> "dataset.list.teamWritesFailed" + teamsCumulative <- teamService.allowedTeamsForDataset(dataset, cumulative = true, requestingUserOpt) ?~> "dataset.list.fetchAllowedTeamsFailed" + teamsCumulativeJs <- Fox.serialCombined(teamsCumulative)(t => teamService.publicWrites(t, Some(organization))) ?~> "dataset.list.teamWritesFailed" + logoUrl <- logoUrlFor(dataset, Some(organization)) ?~> "dataset.list.fetchLogoUrlFailed" + isEditable <- isEditableBy(dataset, requestingUserOpt, requestingUserTeamManagerMemberships) ?~> "dataset.list.isEditableCheckFailed" + lastUsedByUser <- lastUsedTimeFor(dataset._id, requestingUserOpt) ?~> "dataset.list.fetchLastUsedTimeFailed" + dataStoreJs <- dataStoreService.publicWrites(dataStore) ?~> "dataset.list.dataStoreWritesFailed" + dataSource <- dataSourceFor(dataset, Some(organization)) ?~> "dataset.list.fetchDataSourceFailed" usedStorageBytes <- Fox.runIf(requestingUserOpt.exists(u => u._organization == dataset._organization))( - organizationDAO.getUsedStorageForDataset(dataset._id)) ?~> s"fetching used storage failed for ${dataset._id}" + organizationDAO.getUsedStorageForDataset(dataset._id)) } yield { Json.obj( "id" -> dataset._id, diff --git a/app/models/user/UserService.scala b/app/models/user/UserService.scala index be07b9d6115..e8a1872e7fa 100755 --- a/app/models/user/UserService.scala +++ b/app/models/user/UserService.scala @@ -279,11 +279,11 @@ class UserService @Inject()(conf: WkConf, userExperiencesDAO.findAllExperiencesForUser(_user) def teamMembershipsFor(_user: ObjectId): Fox[List[TeamMembership]] = - userDAO.findTeamMembershipsForUser(_user) + userDAO.findTeamMembershipsForUser(_user) ?~> "user.team.memberships.failed" def teamManagerMembershipsFor(_user: ObjectId): Fox[List[TeamMembership]] = for { - teamMemberships <- teamMembershipsFor(_user) + teamMemberships <- teamMembershipsFor(_user) ?~> "user.team.memberships.failed" } yield teamMemberships.filter(_.isTeamManager) def teamManagerTeamIdsFor(_user: ObjectId): Fox[List[ObjectId]] = diff --git a/conf/messages b/conf/messages index 34aa12fa2a7..eb03c7c06f5 100644 --- a/conf/messages +++ b/conf/messages @@ -68,6 +68,7 @@ user.notAuthorised=You are not authorized to view this resource. Please log in. user.id.notFound=We could not find a user id in the request. user.id.invalid=The provided user id is invalid. user.creation.failed=Failed to create user +user.team.memberships.failed=Failed to retrieve team memberships for user oidc.disabled=OIDC is disabled oidc.configuration.invalid=OIDC configuration is invalid @@ -88,7 +89,8 @@ dataset.name.alreadyTaken=This name is already being used by a different dataset dataset.source.usableButNoScale=Dataset {0} is marked as active but has no scale. dataset.import.fileAccessDenied=Cannot create organization folder. Please make sure WEBKNOSSOS has write permissions in the “binaryData” directory dataset.type.invalid=External data set of type “{0}” is not supported -dataset.list.failed=Failed to retrieve list of data sets. +dataset.list.failed=Failed to retrieve list of datasets. +dataset.list.grouping.failed=Failed group retrieved datasets. dataset.list.writesFailed=Failed to write json for dataset {0} dataset.noMags=Data layer does not contain mags dataset.sampledOnlyBlack=Sampled data positions contained only black data