From 54b423dab5333475a871c659c8ac1e7aefe8e08c Mon Sep 17 00:00:00 2001 From: lkruczek Date: Tue, 17 Feb 2026 11:11:30 -0500 Subject: [PATCH 1/3] Add QuickAddCapturesToDatasetView with multi-channel and report --- gateway/sds_gateway/users/views.py | 207 +++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/gateway/sds_gateway/users/views.py b/gateway/sds_gateway/users/views.py index f0b7c634..1c021b63 100644 --- a/gateway/sds_gateway/users/views.py +++ b/gateway/sds_gateway/users/views.py @@ -22,6 +22,7 @@ from django.db.models import Sum from django.db.models.query import QuerySet from django.db.utils import IntegrityError +from django.db.utils import OperationalError from django.http import Http404 from django.http import HttpRequest from django.http import HttpResponse @@ -1694,6 +1695,212 @@ def get(self, request, *args, **kwargs) -> JsonResponse: keyword_autocomplete_api_view = KeywordAutocompleteAPIView.as_view() +class QuickAddCapturesToDatasetView(Auth0LoginRequiredMixin, View): + """ + Quick-add view: add a single capture (and all its channels if multi-channel) + owned by the user to a dataset the user can modify. + Expects POST with JSON body: + {"dataset_uuid": "", "capture_uuid": ""} + Returns error if capture_uuid or dataset_uuid is missing/invalid. + Returns error if user does not have permission to add captures to the dataset. + On success returns added/skipped UUIDs and errors (skipped = already in dataset). + """ + + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> JsonResponse: + data, error = self._parse_json(request) + if error: + return error + assert data is not None # type narrowing: success path only + + # Validate both UUIDs before any ORM access + dataset_uuid, capture_uuid, err = self._validate_quick_add_uuids(data) + if err: + return err + assert dataset_uuid is not None + assert capture_uuid is not None + + # Fetch capture: must be owned by user and not deleted + capture = Capture.objects.filter( + uuid=capture_uuid, + owner=request.user, + is_deleted=False, + ).first() + if not capture: + return JsonResponse( + {"error": "Capture not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Dataset: must exist, not deleted, private (cannot change public datasets) + dataset = Dataset.objects.filter( + uuid=dataset_uuid, + is_deleted=False, + is_public=False, + ).first() + if not dataset: + return JsonResponse( + {"error": "Dataset not found or cannot be modified"}, + status=status.HTTP_404_NOT_FOUND, + ) + # User must have add permission (owner, co-owner, or contributor; not viewer) + if not UserSharePermission.user_can_add_assets( + cast("User", request.user), dataset.uuid, ItemType.DATASET + ): + return JsonResponse( + {"error": "You do not have permission to edit this dataset."}, + status=status.HTTP_403_FORBIDDEN, + ) + + report = self._add_capture_to_dataset_with_report( + capture=capture, + dataset=dataset, + user=cast("User", request.user), + ) + return JsonResponse( + { + "success": True, + "added": [str(u) for u in report["added"]], + "skipped": [str(u) for u in report["skipped"]], + "errors": report["errors"], + }, + status=status.HTTP_200_OK, + ) + + def _validate_quick_add_uuids( + self, data: dict[str, Any] + ) -> tuple[UUID | None, UUID | None, JsonResponse | None]: + """ + Validate dataset_uuid and capture_uuid from request data. + Returns (dataset_uuid, capture_uuid, None) on success, + (None, None, error_response) on validation failure. + """ + dataset_uuid, err = self._validate_uuid(data.get("dataset_uuid")) + if err: + return None, None, err + capture_uuid, err = self._validate_uuid(data.get("capture_uuid")) + if err: + return None, None, err + return dataset_uuid, capture_uuid, None + + def _add_capture_to_dataset_with_report( + self, + capture: "Capture", + dataset: "Dataset", + user: "User", + ) -> dict[str, Any]: + """ + Add a single capture (and its multi-channel siblings if applicable) to a + dataset. Skips captures already in the dataset. Internal use only. + + Returns a dict with keys: "added" (list of UUIDs), "skipped" (list of + UUIDs), "errors" (list of str). + """ + added: list[UUID] = [] + skipped: list[UUID] = [] + errors: list[str] = [] + + if capture.is_multi_channel: + candidates = list( + Capture.objects.filter( + top_level_dir=capture.top_level_dir, + owner=user, + is_deleted=False, + ) + ) + else: + candidates = [capture] + + existing_pks = set( + dataset.captures.filter(pk__in=[c.pk for c in candidates]).values_list( + "pk", flat=True + ) + ) + + for c in candidates: + if c.pk in existing_pks: + skipped.append(c.uuid) + continue + try: + dataset.captures.add(c) + added.append(c.uuid) + except OperationalError as e: + errors.append(f"{c.uuid}: {e}") + except IntegrityError as e: + errors.append(f"{c.uuid}: {e}") + except Exception as e: # noqa: BLE001 - catch-all for unexpected errors + errors.append(f"{c.uuid}: {e}") + + return {"added": added, "skipped": skipped, "errors": errors} + + def _parse_json( + self, request: HttpRequest + ) -> tuple[dict[str, Any] | None, JsonResponse | None]: + """ + Parse request body as JSON and require it to be an object. + Returns (data, None) on success, (None, error_response) on error. + Caller should: data, err = self._parse_json(request); if err: return err + """ + if not request.content_type or "application/json" not in request.content_type: + return None, JsonResponse( + {"error": "Content-Type must be application/json"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not request.body: + return None, JsonResponse( + {"error": "Invalid JSON body"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return None, JsonResponse( + {"error": "Invalid JSON body"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not isinstance(data, dict): + return None, JsonResponse( + {"error": "Request body must be a JSON object"}, + status=status.HTTP_400_BAD_REQUEST, + ) + return data, None + + def _validate_uuid(self, value: Any) -> tuple[UUID | None, JsonResponse | None]: + """ + Validate capture_uuid or dataset_uuid from request data. + Returns (uuid, None) on success, + (None, error_response) on validation failure. + """ + if value is None: + return None, JsonResponse( + {"error": "uuid is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not isinstance(value, str): + return None, JsonResponse( + {"error": "uuid must be a string"}, + status=status.HTTP_400_BAD_REQUEST, + ) + s = value.strip() + if not s: + return None, JsonResponse( + {"error": "uuid cannot be empty"}, + status=status.HTTP_400_BAD_REQUEST, + ) + max_length = 64 + if len(s) > max_length: + return None, JsonResponse( + {"error": "Invalid uuid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return UUID(s), None + except ValueError: + return None, JsonResponse( + {"error": "Invalid uuid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + class GroupCapturesView( Auth0LoginRequiredMixin, FormSearchMixin, FileTreeMixin, TemplateView ): From c1104249b864d851e9d97f71c5c83060fb712b3b Mon Sep 17 00:00:00 2001 From: lkruczek Date: Tue, 17 Feb 2026 15:50:35 -0500 Subject: [PATCH 2/3] Quick add captures to a dataset --- gateway/sds_gateway/static/css/file-list.css | 11 + .../js/actions/QuickAddToDatasetManager.js | 288 ++++++++++++++++++ gateway/sds_gateway/static/js/components.js | 7 +- gateway/sds_gateway/static/js/file-list.js | 116 ++++++- .../templates/users/file_list.html | 35 ++- .../users/partials/captures_page_table.html | 17 +- .../partials/quick_add_to_dataset_modal.html | 43 +++ gateway/sds_gateway/users/urls.py | 12 + gateway/sds_gateway/users/views.py | 42 +++ 9 files changed, 563 insertions(+), 8 deletions(-) create mode 100644 gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js create mode 100644 gateway/sds_gateway/templates/users/partials/quick_add_to_dataset_modal.html diff --git a/gateway/sds_gateway/static/css/file-list.css b/gateway/sds_gateway/static/css/file-list.css index cef2deba..193b27ad 100644 --- a/gateway/sds_gateway/static/css/file-list.css +++ b/gateway/sds_gateway/static/css/file-list.css @@ -574,3 +574,14 @@ body { .progress-bar-width-0 { width: 0%; } + +/* Selection column: hidden by default, shown when selection mode is active */ +#captures-table .capture-select-column { + display: none; + width: 2.5rem; + vertical-align: middle; +} + +#captures-table.selection-mode-active .capture-select-column { + display: table-cell; +} diff --git a/gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js b/gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js new file mode 100644 index 00000000..f4829164 --- /dev/null +++ b/gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js @@ -0,0 +1,288 @@ +/** + * Quick Add to Dataset Manager + * Handles opening the quick-add modal, loading datasets, and adding a capture to a dataset. + */ +class QuickAddToDatasetManager { + constructor() { + this.modalEl = document.getElementById("quickAddToDatasetModal"); + this.currentCaptureUuid = null; + this.currentCaptureName = null; + /** @type {string[]|null} When set, call quick-add API once per UUID (e.g. from file list "Add" button) */ + this.currentCaptureUuids = null; + if (!this.modalEl) return; + this.quickAddUrl = this.modalEl.getAttribute("data-quick-add-url"); + this.datasetsUrl = this.modalEl.getAttribute("data-datasets-url"); + this.selectEl = document.getElementById("quick-add-dataset-select"); + this.confirmBtn = document.getElementById("quick-add-confirm-btn"); + this.messageEl = document.getElementById("quick-add-message"); + this.captureNameEl = document.getElementById("quick-add-capture-name"); + this.initializeEventListeners(); + } + + initializeEventListeners() { + // Delegate click on "Add to dataset" buttons (e.g. in table dropdown) + document.addEventListener("click", (e) => { + const btn = e.target.closest(".add-to-dataset-btn"); + if (!btn) return; + e.preventDefault(); + e.stopPropagation(); + this.currentCaptureUuid = btn.getAttribute("data-capture-uuid"); + this.currentCaptureName = + btn.getAttribute("data-capture-name") || "This capture"; + this.openModal(); + }); + + if (!this.modalEl) return; + + // When modal is shown, load datasets and apply state (single vs multi from file list) + this.modalEl.addEventListener("show.bs.modal", () => { + this.resetMessage(); + const rawIds = this.modalEl.dataset.captureUuids; + if (rawIds) { + try { + this.currentCaptureUuids = JSON.parse(rawIds); + this.currentCaptureUuid = null; + this.currentCaptureName = null; + const n = this.currentCaptureUuids.length; + if (this.captureNameEl) { + this.captureNameEl.textContent = + n === 1 ? "1 capture" : `${n} captures`; + } + delete this.modalEl.dataset.captureUuids; + } catch (_) { + this.currentCaptureUuids = null; + } + } else { + this.currentCaptureUuids = null; + if (this.captureNameEl) { + this.captureNameEl.textContent = + this.currentCaptureName || "This capture"; + } + } + this.loadDatasets(); + }); + + // When dataset select changes, enable/disable Add button + if (this.selectEl) { + this.selectEl.addEventListener("change", () => { + if (this.confirmBtn) { + this.confirmBtn.disabled = !this.selectEl.value; + } + }); + } + + // Add button click + if (this.confirmBtn) { + this.confirmBtn.addEventListener("click", () => this.handleAdd()); + } + } + + openModal() { + if (!this.modalEl) return; + const Modal = window.bootstrap?.Modal; + if (Modal) { + const modal = Modal.getOrCreateInstance(this.modalEl); + modal.show(); + } + } + + resetMessage() { + if (this.messageEl) { + this.messageEl.classList.add("d-none"); + this.messageEl.classList.remove( + "alert-success", + "alert-danger", + "alert-warning", + ); + this.messageEl.textContent = ""; + } + if (this.confirmBtn) { + this.confirmBtn.disabled = true; + } + if (this.selectEl) { + this.selectEl.innerHTML = ''; + } + } + + showMessage(text, type) { + if (!this.messageEl) return; + this.messageEl.textContent = text; + this.messageEl.classList.remove( + "d-none", + "alert-success", + "alert-danger", + "alert-warning", + ); + this.messageEl.classList.add(`alert-${type}`); + } + + async loadDatasets() { + if (!this.selectEl || !this.datasetsUrl) return; + this.selectEl.innerHTML = ''; + if (this.confirmBtn) this.confirmBtn.disabled = true; + try { + const response = await window.APIClient.get(this.datasetsUrl); + const datasets = response.datasets || []; + this.selectEl.innerHTML = ''; + for (const d of datasets) { + const opt = document.createElement("option"); + opt.value = d.uuid; + opt.textContent = d.name; + this.selectEl.appendChild(opt); + } + if (datasets.length === 0) { + this.showMessage( + "You have no datasets you can add captures to.", + "warning", + ); + } + } catch (err) { + this.selectEl.innerHTML = ''; + const reason = err?.data?.error || err?.message || "Try again."; + this.showMessage(`Failed to load datasets. ${reason}`, "danger"); + } + } + + async handleAdd() { + const datasetUuid = this.selectEl?.value; + if (!datasetUuid) return; + const isMulti = + Array.isArray(this.currentCaptureUuids) && + this.currentCaptureUuids.length > 0; + const isSingle = this.currentCaptureUuid && this.quickAddUrl; + if (!isMulti && !isSingle) return; + if (this.confirmBtn) this.confirmBtn.disabled = true; + this.resetMessage(); + if (isMulti) { + await this.handleMultiAdd(datasetUuid); + } else { + await this.handleSingleAdd(datasetUuid); + } + } + + /** + * Build a concise summary from quick-add counts (added, skipped, failed count). + * API returns detailed JSON; we show one short line. + * Failed = request threw (non-2xx HTTP or network) or response.success false or per-capture errors in 200 body. + */ + formatQuickAddSummary(added, skipped, failedCount, firstErrorMessage) { + const parts = []; + if (added > 0) parts.push(`${added} added`); + if (skipped > 0) parts.push(`${skipped} already in dataset`); + if (failedCount > 0) { + parts.push(`${failedCount} failed`); + if (firstErrorMessage != null) { + const text = + typeof firstErrorMessage === "object" + ? (firstErrorMessage.message ?? + firstErrorMessage.detail ?? + String(firstErrorMessage)) + : String(firstErrorMessage); + if (text) parts.push(`: ${text}`); + } + } + return parts.length ? `${parts.join(", ")}.` : "Done."; + } + + /** + * Call quick-add API once per selected capture UUID (loop). Backend handles + * multi-channel grouping per UUID. We aggregate counts and show one concise message. + */ + async handleMultiAdd(datasetUuid) { + if (!this.quickAddUrl) { + this.showMessage("Quick-add URL not configured.", "danger"); + if (this.confirmBtn) this.confirmBtn.disabled = false; + return; + } + let totalAdded = 0; + let totalSkipped = 0; + const errorMessages = []; + for (const captureUuid of this.currentCaptureUuids) { + try { + const response = await window.APIClient.post( + this.quickAddUrl, + { + dataset_uuid: datasetUuid, + capture_uuid: captureUuid, + }, + null, + true, + ); + if (response.success) { + totalAdded += response.added?.length ?? 0; + totalSkipped += response.skipped?.length ?? 0; + if (response.errors?.length) { + errorMessages.push(...(response.errors || [])); + } + } else { + errorMessages.push(response.error || "Request failed"); + } + } catch (err) { + // APIClient throws on non-2xx (and on network errors), so failed = exception or 4xx/5xx + errorMessages.push(err?.data?.error || err?.message || String(err)); + } + } + // failed count = requests that threw (non-200 or network) + response.success false + per-capture errors in 200 response + const errorCount = errorMessages.length; + const hasErrors = errorCount > 0; + const hasSuccess = totalAdded > 0 || totalSkipped > 0; + const msg = this.formatQuickAddSummary( + totalAdded, + totalSkipped, + errorCount, + errorMessages[0], + ); + this.showMessage(msg, hasErrors ? "warning" : "success"); + if (window.showAlert) + window.showAlert(msg, hasErrors ? "warning" : "success"); + if (hasSuccess || !hasErrors) { + setTimeout(() => { + window.bootstrap?.Modal?.getInstance(this.modalEl)?.hide(); + }, 1500); + } else if (this.confirmBtn) { + this.confirmBtn.disabled = false; + } + } + + async handleSingleAdd(datasetUuid) { + try { + const response = await window.APIClient.post( + this.quickAddUrl, + { + dataset_uuid: datasetUuid, + capture_uuid: this.currentCaptureUuid, + }, + null, + true, + ); + if (response.success) { + const added = response.added?.length ?? 0; + const skipped = response.skipped?.length ?? 0; + const errorCount = response.errors?.length ?? 0; + const firstError = response.errors?.[0]; + const msg = this.formatQuickAddSummary( + added, + skipped, + errorCount, + firstError, + ); + this.showMessage(msg, errorCount > 0 ? "warning" : "success"); + if (window.showAlert) + window.showAlert(msg, errorCount > 0 ? "warning" : "success"); + setTimeout(() => { + window.bootstrap?.Modal?.getInstance(this.modalEl)?.hide(); + }, 1500); + } else { + this.showMessage(response.error || "Request failed.", "danger"); + if (this.confirmBtn) this.confirmBtn.disabled = false; + } + } catch (err) { + const msg = + err?.data?.error || err?.message || "Failed to add capture to dataset."; + this.showMessage(msg, "danger"); + if (this.confirmBtn) this.confirmBtn.disabled = false; + } + } +} + +window.QuickAddToDatasetManager = QuickAddToDatasetManager; diff --git a/gateway/sds_gateway/static/js/components.js b/gateway/sds_gateway/static/js/components.js index ba3d8948..b2a7c695 100644 --- a/gateway/sds_gateway/static/js/components.js +++ b/gateway/sds_gateway/static/js/components.js @@ -270,7 +270,12 @@ class TableManager { const rows = this.tbody?.querySelectorAll('tr[data-clickable="true"]'); for (const row of rows || []) { row.addEventListener("click", (e) => { - if (e.target.closest("button, a")) return; // Don't trigger on buttons/links + if ( + e.target.closest( + "button, a, .capture-select-checkbox, .capture-select-column", + ) + ) + return; // Don't trigger on buttons/links/selection this.onRowClick(row); }); } diff --git a/gateway/sds_gateway/static/js/file-list.js b/gateway/sds_gateway/static/js/file-list.js index a10fa5a8..9db5dc5a 100644 --- a/gateway/sds_gateway/static/js/file-list.js +++ b/gateway/sds_gateway/static/js/file-list.js @@ -117,6 +117,53 @@ class FileListController { this.initializeAccordions(); this.initializeFrequencyHandling(); this.initializeItemsPerPageHandler(); + this.initializeAddToDatasetButton(); + } + + /** + * Selection mode: one button to enter; when on, show Cancel and Add + */ + initializeAddToDatasetButton() { + const mainBtn = document.getElementById("add-captures-to-dataset-btn"); + const table = document.getElementById("captures-table"); + const modeButtonsWrap = document.getElementById( + "add-to-dataset-mode-buttons", + ); + const cancelBtn = document.getElementById("add-to-dataset-cancel-btn"); + const addBtn = document.getElementById("add-to-dataset-add-btn"); + if (!mainBtn || !table) return; + + const enterSelectionMode = () => { + table.classList.add("selection-mode-active"); + mainBtn.classList.add("d-none"); + mainBtn.setAttribute("aria-pressed", "true"); + if (modeButtonsWrap) modeButtonsWrap.classList.remove("d-none"); + }; + + const exitSelectionMode = () => { + table.classList.remove("selection-mode-active"); + mainBtn.classList.remove("d-none"); + mainBtn.setAttribute("aria-pressed", "false"); + if (modeButtonsWrap) modeButtonsWrap.classList.add("d-none"); + }; + + mainBtn.addEventListener("click", enterSelectionMode); + + if (cancelBtn) { + cancelBtn.addEventListener("click", exitSelectionMode); + } + + if (addBtn) { + addBtn.addEventListener("click", () => { + const modal = document.getElementById("quickAddToDatasetModal"); + if (modal) { + const ids = Array.from(this.tableManager?.selectedCaptureIds ?? []); + modal.dataset.captureUuids = JSON.stringify(ids); + const bsModal = bootstrap.Modal.getOrCreateInstance(modal); + bsModal.show(); + } + }); + } } /** @@ -552,6 +599,64 @@ class FileListCapturesTableManager extends CapturesTableManager { constructor(options) { super(options); this.resultsCountElement = document.getElementById(options.resultsCountId); + this.selectedCaptureIds = new Set(); + this.setupSelectionCheckboxHandler(); + this.setupRowClickSelection(); + } + + /** + * Delegated handler for selection checkboxes: keep selectedCaptureIds in sync + */ + setupSelectionCheckboxHandler() { + document.addEventListener("change", (e) => { + if (!e.target.matches(".capture-select-checkbox")) return; + const uuid = e.target.getAttribute("data-capture-uuid"); + if (!uuid) return; + if (e.target.checked) { + this.selectedCaptureIds.add(uuid); + } else { + this.selectedCaptureIds.delete(uuid); + } + }); + } + + /** + * When selection mode is active, clicking a row toggles its selection (instead of opening the modal). + * Uses capture phase so we run before the row's click handler. + */ + setupRowClickSelection() { + const table = document.getElementById(this.tableId); + if (!table) return; + + table.addEventListener( + "click", + (e) => { + if (!table.classList.contains("selection-mode-active")) return; + if ( + e.target.closest( + "button, a, [data-bs-toggle='dropdown'], .capture-select-checkbox", + ) + ) + return; + const row = e.target.closest("tr"); + if (!row) return; + const checkbox = row.querySelector(".capture-select-checkbox"); + if (!checkbox) return; + const uuid = checkbox.getAttribute("data-capture-uuid"); + if (!uuid) return; + + if (this.selectedCaptureIds.has(uuid)) { + this.selectedCaptureIds.delete(uuid); + checkbox.checked = false; + } else { + this.selectedCaptureIds.add(uuid); + checkbox.checked = true; + } + e.preventDefault(); + e.stopPropagation(); + }, + true, + ); } /** @@ -608,7 +713,7 @@ class FileListCapturesTableManager extends CapturesTableManager { if (!hasResults || captures.length === 0) { tbody.innerHTML = ` - + No captures found matching your search criteria. @@ -727,8 +832,15 @@ class FileListCapturesTableManager extends CapturesTableManager { // Check if owner (for conditional actions) const isOwner = capture.is_owner !== false; // Default to true if not specified + const checked = this.selectedCaptureIds.has(capture.uuid) ? " checked" : ""; return ` - + + + + Upload Result {% include "users/partials/captures_page_table.html" %} + +
+ + + + + +
{% include "users/partials/capture_modal.html" %} + + {% include "users/partials/quick_add_to_dataset_modal.html" %} {% if VISUALIZATIONS_ENABLED %} {% include "visualizations/partials/visualization_modal.html" with visualization_compatibility=visualization_compatibility %} @@ -395,6 +420,7 @@ + @@ -514,11 +540,16 @@ permissions: permissionsManager }); - // Initialize visualization modal - if (window.VisualizationModal) { + // Initialize visualization modal only when the modal is in the DOM (VISUALIZATIONS_ENABLED) + if (window.VisualizationModal && document.getElementById("visualization-modal")) { new window.VisualizationModal(); } + // Initialize quick add to dataset modal (used by "Add capture(s) to dataset" flow) + if (window.QuickAddToDatasetManager && document.getElementById("quickAddToDatasetModal")) { + new window.QuickAddToDatasetManager(); + } + window.currentDownloadManager = downloadManager; // Initialize share action managers for each capture modal diff --git a/gateway/sds_gateway/templates/users/partials/captures_page_table.html b/gateway/sds_gateway/templates/users/partials/captures_page_table.html index e0ca7a9f..af04f19f 100644 --- a/gateway/sds_gateway/templates/users/partials/captures_page_table.html +++ b/gateway/sds_gateway/templates/users/partials/captures_page_table.html @@ -16,13 +16,18 @@
- + + + diff --git a/gateway/sds_gateway/templates/users/partials/quick_add_to_dataset_modal.html b/gateway/sds_gateway/templates/users/partials/quick_add_to_dataset_modal.html new file mode 100644 index 00000000..cea1d5e4 --- /dev/null +++ b/gateway/sds_gateway/templates/users/partials/quick_add_to_dataset_modal.html @@ -0,0 +1,43 @@ + + diff --git a/gateway/sds_gateway/users/urls.py b/gateway/sds_gateway/users/urls.py index 8a45d3a0..037d11b7 100644 --- a/gateway/sds_gateway/users/urls.py +++ b/gateway/sds_gateway/users/urls.py @@ -11,12 +11,14 @@ from .views import generate_api_key_form_view from .views import keyword_autocomplete_api_view from .views import new_api_key_view +from .views import quick_add_capture_to_dataset_view from .views import render_html_fragment_view from .views import revoke_api_key_view from .views import user_api_key_view from .views import user_captures_api_view from .views import user_dataset_details_view from .views import user_dataset_list_view +from .views import user_datasets_for_quick_add_view from .views import user_detail_view from .views import user_download_item_view from .views import user_file_detail_view @@ -46,6 +48,16 @@ path("files//content/", FileContentView.as_view(), name="file_content"), path("files//h5info/", FileH5InfoView.as_view(), name="file_h5info"), path("dataset-list/", user_dataset_list_view, name="dataset_list"), + path( + "datasets-for-quick-add/", + user_datasets_for_quick_add_view, + name="datasets_for_quick_add", + ), + path( + "quick-add-capture-to-dataset/", + quick_add_capture_to_dataset_view, + name="quick_add_capture_to_dataset", + ), path("search-datasets/", user_search_datasets_view, name="search_datasets"), path("dataset-details/", user_dataset_details_view, name="dataset_details"), path( diff --git a/gateway/sds_gateway/users/views.py b/gateway/sds_gateway/users/views.py index 1c021b63..5ec28858 100644 --- a/gateway/sds_gateway/users/views.py +++ b/gateway/sds_gateway/users/views.py @@ -1901,6 +1901,9 @@ def _validate_uuid(self, value: Any) -> tuple[UUID | None, JsonResponse | None]: ) +quick_add_capture_to_dataset_view = QuickAddCapturesToDatasetView.as_view() + + class GroupCapturesView( Auth0LoginRequiredMixin, FormSearchMixin, FileTreeMixin, TemplateView ): @@ -3173,6 +3176,45 @@ def _apply_sorting( user_dataset_list_view = ListDatasetsView.as_view() +class UserDatasetsForQuickAddView(Auth0LoginRequiredMixin, View): + """Return JSON list of datasets the user can add captures to (quick-add modal).""" + + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> JsonResponse: + user = cast("User", request.user) + owned = ( + user.datasets.filter(is_deleted=False, is_public=False) + .order_by("name") + .values("uuid", "name") + ) + shared_perms = UserSharePermission.objects.filter( + shared_with=user, + item_type=ItemType.DATASET, + is_deleted=False, + is_enabled=True, + permission_level__in=[ + PermissionLevel.CO_OWNER, + PermissionLevel.CONTRIBUTOR, + ], + ) + shared_uuids = [p.item_uuid for p in shared_perms] + shared = ( + Dataset.objects.filter( + uuid__in=shared_uuids, is_deleted=False, is_public=False + ) + .exclude(owner=user) + .order_by("name") + .values("uuid", "name") + ) + datasets = [ + {"uuid": str(d["uuid"]), "name": (d["name"] or "Unnamed")} + for d in list(owned) + list(shared) + ] + return JsonResponse({"datasets": datasets}) + + +user_datasets_for_quick_add_view = UserDatasetsForQuickAddView.as_view() + + class HomePageView(TemplateView): """View for the home page with search form and latest datasets.""" From 8c30df5c33cc37cd5dea7acbc51fd547abda7344 Mon Sep 17 00:00:00 2001 From: lkruczek Date: Tue, 17 Feb 2026 15:58:31 -0500 Subject: [PATCH 3/3] Added owner filter in capture table rows --- gateway/sds_gateway/static/js/file-list.js | 17 +++++++++-------- .../users/partials/captures_page_table.html | 12 ++++++++---- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/gateway/sds_gateway/static/js/file-list.js b/gateway/sds_gateway/static/js/file-list.js index 9db5dc5a..9d7bae8b 100644 --- a/gateway/sds_gateway/static/js/file-list.js +++ b/gateway/sds_gateway/static/js/file-list.js @@ -829,18 +829,19 @@ class FileListCapturesTableManager extends CapturesTableManager { ` : ""; - // Check if owner (for conditional actions) - const isOwner = capture.is_owner !== false; // Default to true if not specified + // Check if owner (for conditional actions and selection — only owned captures are selectable) + const isOwner = capture.is_owner === true; const checked = this.selectedCaptureIds.has(capture.uuid) ? " checked" : ""; - return ` - - + aria-label="Select capture ${nameDisplay}"${checked}>` + : ''; + return ` + +
Search results for captures showing name, channel, creation date, type, author, center frequency, sample rate, and actions
Select Name {% if captures %} {% for cap in captures %} -
+ + - + No captures found matching your search criteria.
- -
${selectCell} - + {% if cap.is_owner %} + + {% else %} + + {% endif %}