diff --git a/gateway/config/urls.py b/gateway/config/urls.py index 09f3d4ba..b8e90ab6 100644 --- a/gateway/config/urls.py +++ b/gateway/config/urls.py @@ -5,16 +5,16 @@ from django.urls import include from django.urls import path from django.views import defaults as default_views -from django.views.generic import TemplateView from drf_spectacular.views import SpectacularAPIView from drf_spectacular.views import SpectacularSwaggerView from loguru import logger as log from rest_framework.authtoken.views import obtain_auth_token from rest_framework.permissions import AllowAny +from sds_gateway.users.views import home_page_view from sds_gateway.users.views import spx_dac_dataset_alt_view urlpatterns = [ - path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), + path("", home_page_view, name="home"), # Django Admin, use {% url 'admin:index' %} path(settings.ADMIN_URL, admin.site.urls), # User management diff --git a/gateway/sds_gateway/static/css/components.css b/gateway/sds_gateway/static/css/components.css index c935eded..d85227aa 100644 --- a/gateway/sds_gateway/static/css/components.css +++ b/gateway/sds_gateway/static/css/components.css @@ -1731,3 +1731,38 @@ body { border: none; color: var(--bs-danger-dark); } + +.keyword-chips-wrapper { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 0.75rem; + min-height: 38px; +} + +.keyword-chips-wrapper:focus-within { + border-color: #86b7fe; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); +} + +.keyword-input { + border: none; + outline: none; + flex: 1; + min-width: 120px; +} + +.keyword-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +.keyword-chip .btn-close { + font-size: 0.65em; + padding: 0.125rem; +} diff --git a/gateway/sds_gateway/static/js/actions/PublishActionManager.js b/gateway/sds_gateway/static/js/actions/PublishActionManager.js index b803d709..956af428 100644 --- a/gateway/sds_gateway/static/js/actions/PublishActionManager.js +++ b/gateway/sds_gateway/static/js/actions/PublishActionManager.js @@ -397,14 +397,10 @@ class PublishActionManager { * @param {string} type - Type of notification (success, error, info) */ showNotification(message, type = "info") { - // Use existing notification system if available - if (typeof showAlert === "function") { - showAlert(message, type); - } else if (typeof window.showAlert === "function") { - window.showAlert(message, type); + if (window.DOMUtils?.showAlert) { + window.DOMUtils.showAlert(message, type); } else { - // Fallback to alert - alert(message); + console.error("DOMUtils not available"); } } } diff --git a/gateway/sds_gateway/static/js/search/DatasetSearchHandler.js b/gateway/sds_gateway/static/js/search/DatasetSearchHandler.js new file mode 100644 index 00000000..4c114f71 --- /dev/null +++ b/gateway/sds_gateway/static/js/search/DatasetSearchHandler.js @@ -0,0 +1,118 @@ +/** + * Dataset Search Handler + * Handles search functionality for published datasets + */ +class DatasetSearchHandler { + /** + * Initialize dataset search handler + * @param {Object} config - Configuration object + */ + constructor(config) { + this.searchForm = document.getElementById(config.searchFormId); + this.searchButton = document.getElementById(config.searchButtonId); + this.clearButton = document.getElementById(config.clearButtonId); + this.resultsContainer = document.getElementById(config.resultsContainerId); + this.resultsTbody = document.getElementById(config.resultsTbodyId); + this.resultsCount = document.getElementById(config.resultsCountId); + + this.initializeEventListeners(); + } + + /** + * Initialize event listeners + */ + initializeEventListeners() { + // Search form submission + if (this.searchForm) { + this.searchForm.addEventListener("submit", (e) => { + e.preventDefault(); + this.handleSearch(); + }); + } + + // Clear button + if (this.clearButton) { + this.clearButton.addEventListener("click", (e) => { + e.preventDefault(); + this.handleClear(); + }); + } + + // Enter key listener for search inputs + this.initializeEnterKeyListener(); + } + + /** + * Initialize enter key listener for search inputs + */ + initializeEnterKeyListener() { + if (!this.searchForm) { + return; + } + + const searchInputs = this.searchForm.querySelectorAll( + "input[type='text'], input[type='number']", + ); + for (const input of searchInputs) { + input.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + this.handleSearch(); + } + }); + } + } + + /** + * Handle search form submission + */ + handleSearch() { + if (!this.searchForm) { + return; + } + + // Get form data + const formData = new FormData(this.searchForm); + const params = new URLSearchParams(); + + // Add non-empty form values to params + for (const [key, value] of formData.entries()) { + if (value && value.trim() !== "") { + params.append(key, value.trim()); + } + } + + // Build URL with search parameters + const searchUrl = `${window.location.pathname}?${params.toString()}`; + + // Navigate to search URL (this will trigger a page reload with results) + window.location.href = searchUrl; + } + + /** + * Handle clear button click + */ + handleClear() { + if (!this.searchForm) { + return; + } + + // Clear all form inputs + const inputs = this.searchForm.querySelectorAll("input, select, textarea"); + for (const input of inputs) { + if (input.type === "checkbox" || input.type === "radio") { + input.checked = false; + } else { + input.value = ""; + } + } + + // Navigate to base search URL (no parameters) + window.location.href = window.location.pathname; + } +} + +// Export for use in other scripts +if (typeof window !== "undefined") { + window.DatasetSearchHandler = DatasetSearchHandler; +} diff --git a/gateway/sds_gateway/static/js/search/KeywordChipInput.js b/gateway/sds_gateway/static/js/search/KeywordChipInput.js new file mode 100644 index 00000000..188153d3 --- /dev/null +++ b/gateway/sds_gateway/static/js/search/KeywordChipInput.js @@ -0,0 +1,302 @@ +class KeywordChipInput { + constructor(inputElement, hiddenInputElement) { + this.input = inputElement; + this.hiddenInput = hiddenInputElement; + this.chips = []; + this.chipContainer = inputElement.parentElement; + this.init(); + } + + init() { + // Load existing keywords from hidden input + this.loadFromHiddenInput(); + + // Event listeners + this.input.addEventListener("keydown", this.handleKeyDown.bind(this)); + this.input.addEventListener("blur", this.handleBlur.bind(this)); + this.input.addEventListener("paste", this.handlePaste.bind(this)); + + // Render initial chips + this.renderChips(); + } + + loadFromHiddenInput() { + const value = this.hiddenInput.value || ""; + if (value.trim()) { + this.chips = value + .split(",") + .map((k) => k.trim()) + .filter((k) => k); + } + } + + handleKeyDown(e) { + // Create chip on comma or Enter + if (e.key === "," || e.key === "Enter") { + e.preventDefault(); + this.addChip(this.input.value.trim()); + } + // Remove last chip on Backspace if input is empty + else if ( + e.key === "Backspace" && + this.input.value === "" && + this.chips.length > 0 + ) { + this.removeChip(this.chips.length - 1); + } + } + + handleBlur() { + // Add any remaining text as a chip when input loses focus + if (this.input.value.trim()) { + this.addChip(this.input.value.trim()); + } + } + + handlePaste(e) { + // Allow paste, then process after a short delay + setTimeout(() => { + const value = this.input.value; + if (value.includes(",")) { + const keywords = value + .split(",") + .map((k) => k.trim()) + .filter((k) => k); + this.input.value = ""; + for (const keyword of keywords) { + this.addChip(keyword); + } + } + }, 10); + } + + addChip(keyword) { + if (!keyword) return; + + // Prevent duplicates + if (this.chips.includes(keyword)) { + this.input.value = ""; + return; + } + + this.chips.push(keyword); + this.input.value = ""; + this.renderChips(); + this.updateHiddenInput(); + } + + removeChip(index) { + if (index >= 0 && index < this.chips.length) { + this.chips.splice(index, 1); + this.renderChips(); + this.updateHiddenInput(); + this.input.focus(); + } + } + + removeChipByKeyword(keyword) { + const index = this.chips.indexOf(keyword); + if (index !== -1) { + this.removeChip(index); + } + } + + renderChips() { + // Remove existing chips (but keep the input) + const existingChips = this.chipContainer.querySelectorAll(".keyword-chip"); + for (const chip of existingChips) { + chip.remove(); + } + + // Add chips before the input + this.chips.forEach((keyword, index) => { + const chip = document.createElement("span"); + chip.className = + "keyword-chip badge bg-secondary d-inline-flex align-items-center gap-1 me-1"; + chip.innerHTML = ` + ${this.escapeHtml(keyword)} + + `; + + // Add remove handler - use keyword instead of index to avoid index issues + const removeBtn = chip.querySelector(".btn-close"); + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const keywordToRemove = removeBtn.getAttribute("data-keyword"); + this.removeChipByKeyword(keywordToRemove); + }); + + // Insert before input + this.chipContainer.insertBefore(chip, this.input); + }); + } + + updateHiddenInput() { + this.hiddenInput.value = this.chips.join(","); + } + + escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + getKeywords() { + return this.chips; + } + + clear() { + this.chips = []; + this.input.value = ""; + this.renderChips(); + this.updateHiddenInput(); + } +} + +/** + * Reusable initializer for KeywordChipInput components + * Handles initialization across multiple pages with different container contexts + */ + +/** + * Initialize keyword chip input for a given container + * @param {HTMLElement|string} container - Container element or selector to search within + * @param {Object} options - Configuration options + * @param {boolean} options.allowMultiple - Allow multiple instances (default: false) + * @param {string} options.wrapperSelector - Custom selector for keyword wrapper (default: '.keyword-chips-wrapper') + * @param {string} options.inputSelector - Custom selector for input (default: '.keyword-input, input[type="text"]') + * @param {string} options.hiddenInputId - Custom ID for hidden input (default: 'keywords-hidden') + * @returns {KeywordChipInput|null} Initialized KeywordChipInput instance or null + */ +function initializeKeywordChipInput(container = document, options = {}) { + const { + allowMultiple = false, + wrapperSelector = ".keyword-chips-wrapper", + inputSelector = '.keyword-input, input[type="text"]', + hiddenInputId = "keywords-hidden", + } = options; + + // Get container element + const containerEl = + typeof container === "string" + ? document.querySelector(container) + : container; + + if (!containerEl) { + console.warn("KeywordChipInputInitializer: Container not found"); + return null; + } + + // Check if KeywordChipInput class is available + if (typeof KeywordChipInput === "undefined") { + console.warn( + "KeywordChipInputInitializer: KeywordChipInput class not loaded", + ); + return null; + } + + // Find the keyword wrapper + const keywordsWrapper = containerEl.querySelector(wrapperSelector); + if (!keywordsWrapper) { + return null; // No keyword input on this page, silently return + } + + // Find the input and hidden input + const keywordsInput = keywordsWrapper.querySelector(inputSelector); + const keywordsHidden = document.getElementById(hiddenInputId); + + if (!keywordsInput || !keywordsHidden) { + console.warn("KeywordChipInputInitializer: Required elements not found"); + return null; + } + + // Check if already initialized (unless multiple instances allowed) + if (!allowMultiple && window.keywordChipInput) { + return window.keywordChipInput; + } + + // Ensure the input has the right classes and styles + keywordsInput.classList.add("keyword-input", "border-0", "flex-grow-1"); + if (!keywordsInput.style.minWidth) { + keywordsInput.style.minWidth = "120px"; + } + if (!keywordsInput.style.outline) { + keywordsInput.style.outline = "none"; + } + if (!keywordsInput.placeholder) { + keywordsInput.placeholder = "Type keywords and press comma"; + } + + // Initialize chip input + const chipInput = new KeywordChipInput(keywordsInput, keywordsHidden); + + // Store globally if not allowing multiple instances + if (!allowMultiple) { + window.keywordChipInput = chipInput; + } + + return chipInput; +} + +/** + * Initialize keyword chip input when a Bootstrap collapse section is shown + * @param {HTMLElement|string} collapseElement - Collapse element or selector + * @param {Object} options - Configuration options (same as initialize) + * @returns {KeywordChipInput|null} Initialized KeywordChipInput instance or null + */ +function initializeKeywordChipInputOnCollapseShow( + collapseElement, + options = {}, +) { + const collapseEl = + typeof collapseElement === "string" + ? document.querySelector(collapseElement) + : collapseElement; + + if (!collapseEl || !window.bootstrap) { + return null; + } + + // Initialize immediately if collapse is already shown + if (collapseEl.classList.contains("show")) { + return initializeKeywordChipInput(collapseEl, options); + } + + // Otherwise, wait for the collapse to be shown + collapseEl.addEventListener("shown.bs.collapse", () => { + initializeKeywordChipInput(collapseEl, options); + }); + + return null; +} + +/** + * Auto-initialize on DOMContentLoaded + * Looks for keyword chip inputs in the document and initializes them + * @param {Object} options - Configuration options (same as initialize) + */ +function autoInitializeKeywordChipInput(options = {}) { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + initializeKeywordChipInput(document, options); + }); + } else { + // DOM already loaded + initializeKeywordChipInput(document, options); + } +} + +// Export for use in other scripts +if (typeof window !== "undefined") { + window.KeywordChipInput = KeywordChipInput; + // Maintain backward compatibility with class-based API + window.KeywordChipInputInitializer = { + initialize: initializeKeywordChipInput, + initializeOnCollapseShow: initializeKeywordChipInputOnCollapseShow, + autoInitialize: autoInitializeKeywordChipInput, + }; +} diff --git a/gateway/sds_gateway/templates/pages/home.html b/gateway/sds_gateway/templates/pages/home.html index 22d4b0c0..d689d5f9 100644 --- a/gateway/sds_gateway/templates/pages/home.html +++ b/gateway/sds_gateway/templates/pages/home.html @@ -6,19 +6,102 @@ {% block title %} SDS Gateway {% endblock title %} +{% block head %} + +{% endblock head %} {% block content %} {% endblock content %} {% block body %}
-
-
-

SpectrumX Data System

-

- The SpectrumX Data System (SDS) is a key component of +

+
+

SpectrumX Data System

+

+ The SpectrumX Data System (SDS) is a key component of SpectrumX Flagship Project 1: Spectrum Awareness for Coexistence in support of SpectrumX's vision to be a trusted resource in the spectrum ecosystem. SpectrumX aims to provide objective, long-term, and innovative policy and technical contributions through collaborative, inclusive, and integrative education and research activities. -

+

+
+
+
+
+
+ {% if latest_datasets %} +
+
+
+

Latest Public Datasets

+ + View and search more public datasets + +
+ +
+ {% for dataset in latest_datasets %} +
+
+
+
+ {{ dataset.name }} + {% if dataset.is_public %}{% endif %} +
+
+ + {% if dataset.dataset.created_at %} + {{ dataset.dataset.created_at|date:"Y-m-d" }} + {% endif %} + +
+ {% if dataset.authors %} +

+ + + {% for author in dataset.authors|slice:":2" %} + {% if author.name %} + {{ author.name }} + {% else %} + {{ author }} + {% endif %} + {% if not forloop.last %},{% endif %} + {% endfor %} + {% if dataset.authors|length > 2 %}...{% endif %} + +

+ {% endif %} + {% if dataset.keywords %} +
+ {% for keyword in dataset.keywords|slice:":3" %} + {{ keyword }} + {% endfor %} + {% if dataset.keywords|length > 3 %} + +{{ dataset.keywords|length|add:"-3" }} + {% endif %} +
+ {% endif %} +
+
+
+ {% endfor %} +
+
+
+ {% else %} +
+
+ +
+
+ {% endif %}
@@ -63,4 +146,44 @@

SpectrumX Website

+ + {% include "users/partials/dataset_details_modal.html" %} {% endblock body %} +{% block javascript %} + {{ block.super }} + + + + + +{% endblock javascript %} diff --git a/gateway/sds_gateway/templates/users/dataset_list.html b/gateway/sds_gateway/templates/users/dataset_list.html index bd74946f..d07b2e87 100644 --- a/gateway/sds_gateway/templates/users/dataset_list.html +++ b/gateway/sds_gateway/templates/users/dataset_list.html @@ -20,276 +20,36 @@
-

Datasets

+

My Datasets

Add Dataset
-
- - {% if page_obj.object_list %} -
- - {{ page_obj.paginator.count }} dataset{{ page_obj.paginator.count|pluralize }} found - -
- {% endif %} - -
-
-
- {% if not page_obj %} - - - - - - - - - - -
Your datasets list
- Dataset Name - - Author - - Created At - Actions
- - {% else %} - - - - - - - - - - - - {% for dataset in page_obj %} - - - - - - - {% endfor %} - -
Your datasets list
- Dataset Name - - Author - - Created At - Actions
- {% if dataset.is_owner %} - - {{ dataset.name }} - - {% else %} - {{ dataset.name }} - {% endif %} - {% if dataset.status == 'draft' %} - {{ dataset.status_display }} - {% endif %} - {% if dataset.is_public %}{% endif %} - {% if dataset.is_shared_with_me %} - - - - {% endif %} - - {% if dataset.authors %} - {% for author in dataset.authors %} - {% if forloop.counter <= 2 %} - {% if author.name %} - {% if author.orcid_id %} - - {{ author.name }} - - - {% else %} - {{ author.name }} - {% endif %} - {% if not forloop.last and forloop.counter < 2 and dataset.authors|length > 1 %},{% endif %} - {% else %} - {{ author }} - {% if not forloop.last and forloop.counter < 2 and dataset.authors|length > 1 %},{% endif %} - {% endif %} - {% endif %} - {% endfor %} - {% if dataset.authors|length > 2 %}...{% endif %} - {% else %} - - - {% endif %} - - {% if dataset.dataset.created_at %} - - {% localtime on %} -
- {{ dataset.dataset.created_at|date:"Y-m-d" }} - {{ dataset.dataset.created_at|date:"H:i:s T" }} -
- {% endlocaltime %} - {% else %} - - - {% endif %} -
- -
- {% endif %} -
-
+ {% include "users/partials/my_datasets_tab.html" %}
- - {% if page_obj.object_list %} - - {% endif %}
- - -{% include "users/partials/web_download_modal.html" %} -{% include "users/partials/dataset_details_modal.html" %} -{% for dataset in page_obj %} - {% include "users/partials/share_modal.html" with item=dataset item_type="dataset" %} - {% include "users/partials/sdk_download_modal.html" with dataset=dataset %} - {% if dataset.is_owner or dataset.permission_level == 'co-owner' %} - {% if not dataset.dataset.status == 'final' or not dataset.is_public %} - {% include "users/partials/publish_dataset_modal.html" with dataset=dataset %} - {% endif %} + + {% include "users/partials/web_download_modal.html" %} + {% include "users/partials/dataset_details_modal.html" %} + {% if page_obj %} + {% for dataset in page_obj %} + {% include "users/partials/share_modal.html" with item=dataset item_type="dataset" %} + {% include "users/partials/sdk_download_modal.html" with dataset=dataset %} + {% if dataset.is_owner or dataset.permission_level == 'co-owner' %} + {% if not dataset.dataset.status == 'final' or not dataset.is_public %} + {% include "users/partials/publish_dataset_modal.html" with dataset=dataset %} + {% endif %} + {% endif %} + {% endfor %} {% endif %} -{% endfor %} {% endblock content %} {% block javascript %} {# djlint:off #} {{ block.super }} + diff --git a/gateway/sds_gateway/templates/users/partials/dataset_search_form.html b/gateway/sds_gateway/templates/users/partials/dataset_search_form.html new file mode 100644 index 00000000..64a0bfd6 --- /dev/null +++ b/gateway/sds_gateway/templates/users/partials/dataset_search_form.html @@ -0,0 +1,73 @@ +{% load static %} + + +
+
+

Search Published Datasets

+
+
+
+ + {{ search_form.query }} + {% if search_form.query.help_text %} + {{ search_form.query.help_text }} + {% endif %} +
+
+ +
+
+ + +
+ + +
+ {% if search_form.keywords.help_text %} + {{ search_form.keywords.help_text }} + {% endif %} +
+
+ + {{ search_form.min_frequency }} + {% if search_form.min_frequency.help_text %} + {{ search_form.min_frequency.help_text }} + {% endif %} +
+
+ + {{ search_form.max_frequency }} + {% if search_form.max_frequency.help_text %} + {{ search_form.max_frequency.help_text }} + {% endif %} +
+
+ + {% if show_clear_button %} + + Clear + + {% endif %} +
+
+
+
+
diff --git a/gateway/sds_gateway/templates/users/partials/my_datasets_tab.html b/gateway/sds_gateway/templates/users/partials/my_datasets_tab.html new file mode 100644 index 00000000..c7e40c44 --- /dev/null +++ b/gateway/sds_gateway/templates/users/partials/my_datasets_tab.html @@ -0,0 +1,242 @@ +{% load tz %} + + +
+ +{% if page_obj.object_list %} +
+ + {{ page_obj.paginator.count }} dataset{{ page_obj.paginator.count|pluralize }} found + +
+{% endif %} + +
+
+
+ {% if not page_obj %} + + + + + + + + + + +
Your datasets list
+ Dataset Name + + Author + + Created At + Actions
+ + {% else %} + + + + + + + + + + + + {% for dataset in page_obj %} + + + + + + + {% endfor %} + +
Your datasets list
+ Dataset Name + + Author + + Created At + Actions
+ {% if dataset.is_owner %} + {{ dataset.name }} + {% else %} + {{ dataset.name }} + {% endif %} + {% if dataset.status == 'draft' %} + {{ dataset.status_display }} + {% endif %} + {% if dataset.is_public %}{% endif %} + {% if dataset.is_shared_with_me %} + + + + {% endif %} + + {% if dataset.authors %} + {% for author in dataset.authors %} + {% if forloop.counter <= 2 %} + {% if author.name %} + {% if author.orcid_id %} + + {{ author.name }} + + + {% else %} + {{ author.name }} + {% endif %} + {% if not forloop.last and forloop.counter < 2 and dataset.authors|length > 1 %},{% endif %} + {% else %} + {{ author }} + {% if not forloop.last and forloop.counter < 2 and dataset.authors|length > 1 %},{% endif %} + {% endif %} + {% endif %} + {% endfor %} + {% if dataset.authors|length > 2 %}...{% endif %} + {% else %} + - + {% endif %} + + {% if dataset.dataset.created_at %} + {% localtime on %} +
+ {{ dataset.dataset.created_at|date:"Y-m-d" }} + {{ dataset.dataset.created_at|date:"H:i:s T" }} +
+ {% endlocaltime %} + {% else %} + - + {% endif %} +
+ +
+ {% endif %} +
+
+
+ +{% if page_obj and page_obj.has_other_pages %} + +{% endif %} diff --git a/gateway/sds_gateway/templates/users/partials/search_published_datasets_tab.html b/gateway/sds_gateway/templates/users/partials/search_published_datasets_tab.html new file mode 100644 index 00000000..20ec0ffd --- /dev/null +++ b/gateway/sds_gateway/templates/users/partials/search_published_datasets_tab.html @@ -0,0 +1,183 @@ +{% load tz %} + + + +{% include "users/partials/dataset_search_form.html" with show_clear_button=True %} + +{% if page_obj.object_list %} +
+ + {{ page_obj.paginator.count }} dataset{{ page_obj.paginator.count|pluralize }} found + +
+{% endif %} + +
+
+
+ {% if not page_obj or not page_obj.object_list %} + + {% else %} + + + + + + + + + + + + + {% for dataset in page_obj %} + + + + + + + + {% endfor %} + +
Published datasets search results
Dataset NameAuthorsKeywordsCreated AtActions
+ {{ dataset.name }} + {% if dataset.is_public %}{% endif %} + + {% if dataset.authors %} + {% for author in dataset.authors %} + {% if forloop.counter <= 2 %} + {% if author.name %} + {% if author.orcid_id %} + + {{ author.name }} + + + {% else %} + {{ author.name }} + {% endif %} + {% if not forloop.last and forloop.counter < 2 and dataset.authors|length > 1 %},{% endif %} + {% else %} + {{ author }} + {% if not forloop.last and forloop.counter < 2 and dataset.authors|length > 1 %},{% endif %} + {% endif %} + {% endif %} + {% endfor %} + {% if dataset.authors|length > 2 %}...{% endif %} + {% else %} + - + {% endif %} + + {% if dataset.keywords %} + {% for keyword in dataset.keywords|slice:":3" %} + {{ keyword }} + {% endfor %} + {% if dataset.keywords|length > 3 %}...{% endif %} + {% else %} + - + {% endif %} + + {% if dataset.dataset.created_at %} + {% localtime on %} +
+ {{ dataset.dataset.created_at|date:"Y-m-d" }} + {{ dataset.dataset.created_at|date:"H:i:s T" }} +
+ {% endlocaltime %} + {% else %} + - + {% endif %} +
+ +
+ {% endif %} +
+
+ +{% if page_obj and page_obj.has_other_pages %} + +{% endif %} +
diff --git a/gateway/sds_gateway/templates/users/published_datasets_list.html b/gateway/sds_gateway/templates/users/published_datasets_list.html new file mode 100644 index 00000000..55d44920 --- /dev/null +++ b/gateway/sds_gateway/templates/users/published_datasets_list.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% load static %} +{% load custom_filters %} +{% load tz %} + +{% block css %} + {{ block.super }} + +{% endblock css %} +{% block bodyclass %} + hero-white-page +{% endblock bodyclass %} +{% block content %} + {% csrf_token %} + + +
+
+
+

Search Published Datasets

+ + {% include "users/partials/search_published_datasets_tab.html" %} +
+
+
+ + {% include "users/partials/web_download_modal.html" %} + {% include "users/partials/dataset_details_modal.html" %} + {% if page_obj %} + {% for dataset in page_obj %} + {% include "users/partials/sdk_download_modal.html" with dataset=dataset %} + {% endfor %} + {% endif %} +{% endblock content %} +{% block javascript %} + {# djlint:off #} + {{ block.super }} + + + + + + + {# djlint:on #} +{% endblock javascript %} diff --git a/gateway/sds_gateway/templates/users/search_datasets.html b/gateway/sds_gateway/templates/users/search_datasets.html new file mode 100644 index 00000000..116d0d48 --- /dev/null +++ b/gateway/sds_gateway/templates/users/search_datasets.html @@ -0,0 +1,261 @@ +{% extends "base.html" %} + +{% load static %} +{% load custom_filters %} +{% load tz %} + +{% block css %} + {{ block.super }} + +{% endblock css %} +{% block bodyclass %} + hero-white-page +{% endblock bodyclass %} +{% block content %} + {% csrf_token %} +
+
+
+
+

Search Published Datasets

+
+
+ +
+
+
+
+
+ + {{ search_form.query }} + {% if search_form.query.help_text %} + {{ search_form.query.help_text }} + {% endif %} +
+
+ + {{ search_form.keywords }} + {% if search_form.keywords.help_text %} + {{ search_form.keywords.help_text }} + {% endif %} +
+
+ + {{ search_form.min_frequency }} + {% if search_form.min_frequency.help_text %} + {{ search_form.min_frequency.help_text }} + {% endif %} +
+
+ + {{ search_form.max_frequency }} + {% if search_form.max_frequency.help_text %} + {{ search_form.max_frequency.help_text }} + {% endif %} +
+
+ + + Clear + +
+
+
+
+
+ + {% if page_obj.object_list %} +
+ + {{ page_obj.paginator.count }} dataset{{ page_obj.paginator.count|pluralize }} found + +
+ {% endif %} + +
+
+
+ {% if not page_obj or not page_obj.object_list %} + + {% else %} + + + + + + + + + + + + + {% for dataset in page_obj %} + + + + + + + + {% endfor %} + +
Published datasets search results
Dataset NameAuthorsKeywordsCreated AtActions
+ + {{ dataset.name }} + + {% if dataset.is_public %}{% endif %} + + {% if dataset.authors %} + {% for author in dataset.authors %} + {% if forloop.counter <= 2 %} + {% if author.name %} + {% if author.orcid_id %} + + {{ author.name }} + + + {% else %} + {{ author.name }} + {% endif %} + {% if not forloop.last and forloop.counter < 2 and dataset.authors|length > 1 %},{% endif %} + {% else %} + {{ author }} + {% if not forloop.last and forloop.counter < 2 and dataset.authors|length > 1 %},{% endif %} + {% endif %} + {% endif %} + {% endfor %} + {% if dataset.authors|length > 2 %}...{% endif %} + {% else %} + - + {% endif %} + + {% if dataset.keywords %} + {% for keyword in dataset.keywords.all|slice:":3" %} + {{ keyword.name }} + {% endfor %} + {% if dataset.keywords.count > 3 %}...{% endif %} + {% else %} + - + {% endif %} + + {% if dataset.dataset.created_at %} + {% localtime on %} +
+ {{ dataset.dataset.created_at|date:"Y-m-d" }} + {{ dataset.dataset.created_at|date:"H:i:s T" }} +
+ {% endlocaltime %} + {% else %} + - + {% endif %} +
+ +
+ {% endif %} +
+
+ + {% if page_obj and page_obj.has_other_pages %} + + {% endif %} +
+
+
+
+ +{% include "users/partials/dataset_details_modal.html" %} +{% block javascript %} + {{ block.super }} + + +{% endblock javascript %} +{% endblock content %} diff --git a/gateway/sds_gateway/users/forms.py b/gateway/sds_gateway/users/forms.py index 06207da8..4d00998f 100644 --- a/gateway/sds_gateway/users/forms.py +++ b/gateway/sds_gateway/users/forms.py @@ -14,6 +14,7 @@ from django.utils.translation import gettext_lazy as _ from loguru import logger +from sds_gateway.api_methods.models import Dataset from sds_gateway.api_methods.models import File from .models import User @@ -167,6 +168,12 @@ class DatasetInfoForm(forms.Form): "Add authors to the dataset. The first author should be the primary author." ), ) + status = forms.ChoiceField( + label="Status", + required=False, + choices=[], + widget=forms.Select(attrs={"class": "form-select d-none"}), + ) is_public = forms.BooleanField( label="Is Public", required=False, @@ -178,6 +185,7 @@ def __init__(self, *args, **kwargs): user = kwargs.pop("user", None) self.dataset_uuid = kwargs.pop("dataset_uuid", None) super().__init__(*args, **kwargs) + self.fields["status"].choices = Dataset.STATUS_CHOICES initial_authors = self.initial.get("authors") # Check if authors is empty (None, empty string, or "[]") is_authors_empty = not initial_authors or initial_authors in ["", "[]"] @@ -365,3 +373,58 @@ def __init__(self, *args, user=None, **kwargs): ] self.fields["file_extension"].choices = extension_choices + + +class PublishedDatasetSearchForm(forms.Form): + """Form for searching published datasets.""" + + query = forms.CharField( + label="Search", + required=False, + widget=forms.TextInput( + attrs={ + "class": "form-control", + "id": "search-query", + "placeholder": "Search by name, abstract, description, authors...", + } + ), + help_text="Search across dataset name, abstract, description, and authors", + ) + keywords = forms.CharField( + label="Keywords", + required=False, + widget=forms.TextInput( + attrs={ + "class": "form-control", + "id": "search-keywords", + "placeholder": "e.g. spectrum, RF, SDR", + } + ), + help_text="Comma-separated keywords or tags", + ) + min_frequency = forms.FloatField( + label="Min Frequency (GHz)", + required=False, + widget=forms.NumberInput( + attrs={ + "class": "form-control", + "id": "search-min-freq", + "placeholder": "0.0", + "step": "0.001", + } + ), + help_text="Minimum frequency in GHz", + ) + max_frequency = forms.FloatField( + label="Max Frequency (GHz)", + required=False, + widget=forms.NumberInput( + attrs={ + "class": "form-control", + "id": "search-max-freq", + "placeholder": "100.0", + "step": "0.001", + } + ), + help_text="Maximum frequency in GHz", + ) diff --git a/gateway/sds_gateway/users/urls.py b/gateway/sds_gateway/users/urls.py index 3d3137f6..8a45d3a0 100644 --- a/gateway/sds_gateway/users/urls.py +++ b/gateway/sds_gateway/users/urls.py @@ -23,6 +23,7 @@ from .views import user_group_captures_view from .views import user_publish_dataset_view from .views import user_redirect_view +from .views import user_search_datasets_view from .views import user_share_group_list_view from .views import user_share_item_view from .views import user_temporary_zip_download_view @@ -45,6 +46,7 @@ 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("search-datasets/", user_search_datasets_view, name="search_datasets"), path("dataset-details/", user_dataset_details_view, name="dataset_details"), path( "api/keyword-autocomplete/", diff --git a/gateway/sds_gateway/users/views.py b/gateway/sds_gateway/users/views.py index cbc907f1..2b293ef1 100644 --- a/gateway/sds_gateway/users/views.py +++ b/gateway/sds_gateway/users/views.py @@ -88,6 +88,7 @@ from sds_gateway.users.forms import CaptureSearchForm from sds_gateway.users.forms import DatasetInfoForm from sds_gateway.users.forms import FileSearchForm +from sds_gateway.users.forms import PublishedDatasetSearchForm from sds_gateway.users.forms import UserUpdateForm from sds_gateway.users.h5_service import H5PreviewService from sds_gateway.users.item_models import Item @@ -2619,11 +2620,144 @@ def _get_capture_context( user_group_captures_view = GroupCapturesView.as_view() +def filter_by_frequency_range( + datasets: QuerySet[Dataset], + min_freq: float | None, + max_freq: float | None, +) -> QuerySet[Dataset]: + """Filter datasets by frequency range of their captures. + + Reuses the existing _apply_frequency_filters_to_list function + to filter captures, then maps back to datasets. + """ + if min_freq is None and max_freq is None: + return datasets + + # Get dataset UUIDs + dataset_uuids = list(datasets.values_list("uuid", flat=True)) + if not dataset_uuids: + return datasets.none() + + # Get all captures for these datasets and convert to list + captures_qs = Capture.objects.filter( + dataset__uuid__in=dataset_uuids, is_deleted=False + ) + captures_list = list(captures_qs.iterator(chunk_size=1000)) + if not captures_list: + return datasets.none() + + # Use existing frequency filter function + filtered_captures = _apply_frequency_filters_to_list( + captures_list=captures_list, + min_freq=min_freq, + max_freq=max_freq, + ) + + # Get dataset IDs from filtered captures + matching_dataset_ids = { + capture.dataset_id + for capture in filtered_captures + if capture.dataset_id is not None + } + if not matching_dataset_ids: + return datasets.none() + + # Get dataset UUIDs from IDs and filter the queryset + matching_dataset_uuids = set( + Dataset.objects.filter(id__in=matching_dataset_ids).values_list( + "uuid", flat=True + ) + ) + return datasets.filter(uuid__in=matching_dataset_uuids) + + +def serialize_datasets_for_user( + datasets: QuerySet[Dataset], user: User | None +) -> list[dict[str, Any]]: + """Serialize datasets for display with user context. + + Args: + datasets: QuerySet of Dataset objects to serialize + user: User object or None for anonymous users + + Returns: + List of serialized dataset dictionaries + """ + serialized_datasets = [] + for dataset in datasets: + # Create a mock request object for the serializer context + context_req = { + "request": type( + "Request", + (), + {"user": user if user and user.is_authenticated else None}, + )() + } + dataset_data = cast( + "ReturnDict", DatasetGetSerializer(dataset, context=context_req).data + ) + dataset_data["dataset"] = dataset + serialized_datasets.append(dataset_data) + return serialized_datasets + + +def get_published_datasets() -> QuerySet[Dataset]: + """Get all published datasets (status=FINAL or is_public=True).""" + return ( + Dataset.objects.filter( + Q(status=DatasetStatus.FINAL) | Q(is_public=True), + is_deleted=False, + ) + .prefetch_related("keywords", "owner") + .distinct() + .order_by("-created_at") + ) + + +def apply_search_filters( + datasets: QuerySet[Dataset], + form_data: dict[str, Any], +) -> QuerySet[Dataset]: + """Apply search filters to the dataset queryset.""" + query = form_data.get("query", "").strip() + keywords_str = form_data.get("keywords", "").strip() + min_freq = form_data.get("min_frequency") + max_freq = form_data.get("max_frequency") + + # Apply text search + if query: + datasets = datasets.filter( + Q(name__icontains=query) + | Q(abstract__icontains=query) + | Q(description__icontains=query) + | Q(authors__icontains=query) + | Q(doi__icontains=query) + ) + + # Apply keyword filter + if keywords_str: + # Split and slugify keywords + keyword_slugs = { + slugify(k.strip()) + for k in keywords_str.split(",") + if k.strip() and slugify(k.strip()) + } + if keyword_slugs: + datasets = datasets.filter(keywords__name__in=keyword_slugs).distinct() + + # Apply frequency range filter + if min_freq is not None or max_freq is not None: + datasets = filter_by_frequency_range(datasets, min_freq, max_freq) + + return datasets + + class ListDatasetsView(Auth0LoginRequiredMixin, View): template_name = "users/dataset_list.html" def get(self, request, *args, **kwargs) -> HttpResponse: """Handle GET request for dataset list.""" + sort_by, sort_order = self._get_sort_parameters(request) order_by = self._build_order_by(sort_by, sort_order) @@ -2632,10 +2766,10 @@ def get(self, request, *args, **kwargs) -> HttpResponse: datasets_with_shared_users: list[dict] = [] # pyright: ignore[reportMissingTypeArgument] datasets_with_shared_users.extend( - self._serialize_datasets(owned_datasets, request.user) + serialize_datasets_for_user(owned_datasets, request.user) ) datasets_with_shared_users.extend( - self._serialize_datasets(shared_datasets, request.user) + serialize_datasets_for_user(shared_datasets, request.user) ) page_obj = self._paginate_datasets(datasets_with_shared_users, request) @@ -2691,23 +2825,6 @@ def _get_shared_datasets(self, user: User, order_by: str) -> QuerySet[Dataset]: .order_by(order_by) ) - def _serialize_datasets( - self, datasets: QuerySet[Dataset], user: User - ) -> list[dict[str, Any]]: - """Prepare serialized datasets.""" - result = [] - for dataset in datasets: - # Use serializer with request context for proper field calculation - context = {"request": type("Request", (), {"user": user})()} - dataset_data = cast( - "ReturnDict", DatasetGetSerializer(dataset, context=context).data - ) - - # Add the original model for template access - dataset_data["dataset"] = dataset - result.append(dataset_data) - return result - def _paginate_datasets( self, datasets: list[dict[str, Any]], request: HttpRequest ) -> Any: @@ -2717,6 +2834,46 @@ def _paginate_datasets( return paginator.get_page(page_number) +class SearchPublishedDatasetsView(View): + """View for searching published datasets (public, no auth required).""" + + template_name = "users/published_datasets_list.html" + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Handle GET request for dataset search.""" + form = PublishedDatasetSearchForm(request.GET) + datasets = get_published_datasets() + + # Apply search filters + if form.is_valid(): + datasets = apply_search_filters( + datasets, + form.cleaned_data, + ) + + # Serialize datasets + serialized_datasets = serialize_datasets_for_user( + datasets, request.user if request.user.is_authenticated else None + ) + + # Paginate results + paginator = Paginator(serialized_datasets, per_page=15) + page_number = request.GET.get("page", 1) + try: + page_obj = paginator.get_page(page_number) + except (PageNotAnInteger, EmptyPage): + page_obj = paginator.get_page(1) + + return render( + request, + template_name=self.template_name, + context={ + "search_form": form, + "page_obj": page_obj, + }, + ) + + def _apply_basic_filters( qs: QuerySet[Capture], search: str | None = None, @@ -2810,6 +2967,55 @@ def _apply_sorting( user_dataset_list_view = ListDatasetsView.as_view() +class HomePageView(TemplateView): + """View for the home page with search form and latest datasets.""" + + template_name = "pages/home.html" + + def get_context_data(self, **kwargs): + """Add search form and latest 5 public datasets to context.""" + context = super().get_context_data(**kwargs) + + # Get latest 5 public published datasets (is_public=True only) + latest_datasets = ( + Dataset.objects.filter( + is_public=True, + is_deleted=False, + ) + .prefetch_related("keywords", "owner") + .distinct() + .order_by("-created_at")[:5] + ) + + # Serialize datasets + serialized_datasets = [] + for dataset in latest_datasets: + context_req = { + "request": type( + "Request", + (), + { + "user": self.request.user + if self.request.user.is_authenticated + else None + }, + )() + } + dataset_data = cast( + "ReturnDict", DatasetGetSerializer(dataset, context=context_req).data + ) + dataset_data["dataset"] = dataset + serialized_datasets.append(dataset_data) + + context["search_form"] = PublishedDatasetSearchForm() + context["latest_datasets"] = serialized_datasets + return context + + +home_page_view = HomePageView.as_view() +user_search_datasets_view = SearchPublishedDatasetsView.as_view() + + class PublishDatasetView(Auth0LoginRequiredMixin, View): """View to handle dataset publishing (updating status and is_public)."""