diff --git a/gateway/sds_gateway/api_methods/admin.py b/gateway/sds_gateway/api_methods/admin.py index 125f6f9a..dcd846cd 100644 --- a/gateway/sds_gateway/api_methods/admin.py +++ b/gateway/sds_gateway/api_methods/admin.py @@ -21,10 +21,19 @@ class CaptureAdmin(admin.ModelAdmin): # pyright: ignore[reportMissingTypeArgume @admin.register(models.Dataset) class DatasetAdmin(admin.ModelAdmin): # pyright: ignore[reportMissingTypeArgument] - list_display = ("name", "doi") - search_fields = ("name", "doi") + list_display = ("name", "doi", "get_keywords", "status", "owner") + search_fields = ("name", "doi", "keywords__name", "owner__email") + list_filter = ("status", "keywords") ordering = ("-updated_at",) + @admin.display(description="Keywords") + def get_keywords(self, obj): + """Display comma-separated list of keywords.""" + keywords = obj.keywords.filter(is_deleted=False) + if keywords.exists(): + return ", ".join([kw.name for kw in keywords[:5]]) + return "-" + @admin.register(models.TemporaryZipFile) class TemporaryZipFileAdmin(admin.ModelAdmin): # pyright: ignore[reportMissingTypeArgument] @@ -85,3 +94,16 @@ class ShareGroupAdmin(admin.ModelAdmin): # pyright: ignore[reportMissingTypeArg list_display = ("name", "owner") search_fields = ("name", "owner") ordering = ("-updated_at",) + + +@admin.register(models.Keyword) +class KeywordAdmin(admin.ModelAdmin): # pyright: ignore[reportMissingTypeArgument] + list_display = ("name", "get_datasets", "created_at") + search_fields = ("name", "datasets__name") + list_filter = ("datasets",) + ordering = ("name",) + + @admin.display(description="Datasets") + def get_datasets(self, obj): + """Display comma-separated list of dataset names.""" + return ", ".join([dataset.name for dataset in obj.datasets.all()[:3]]) diff --git a/gateway/sds_gateway/api_methods/migrations/max_migration.txt b/gateway/sds_gateway/api_methods/migrations/max_migration.txt index f8c6298f..c68e76aa 100644 --- a/gateway/sds_gateway/api_methods/migrations/max_migration.txt +++ b/gateway/sds_gateway/api_methods/migrations/max_migration.txt @@ -1 +1 @@ -0017_convert_dataset_authors_to_object_format +0021_remove_old_dataset_keywords_table diff --git a/gateway/sds_gateway/api_methods/models.py b/gateway/sds_gateway/api_methods/models.py index 9328fe18..26456dbb 100644 --- a/gateway/sds_gateway/api_methods/models.py +++ b/gateway/sds_gateway/api_methods/models.py @@ -18,6 +18,7 @@ from django.db.models.signals import post_save from django.db.models.signals import pre_delete from django.dispatch import receiver +from django.template.defaultfilters import slugify from .utils.opensearch_client import get_opensearch_client @@ -27,6 +28,24 @@ log = logging.getLogger(__name__) +class KeywordNameField(models.CharField): + """ + Custom field that auto-slugifies keyword names for consistency. + Enforces lowercase, converts spaces to hyphens, removes non-printable chars. + """ + + def get_prep_value(self, value): + """Convert the value to a slug before saving to database.""" + value = super().get_prep_value(value) + if value is None: + return value + if not isinstance(value, str): + msg = "Name attribute must be a string" + raise TypeError(msg) + # Slugify the value (lowercase, hyphens instead of spaces, etc.) + return slugify(value) + + class CaptureType(StrEnum): """The type of radiofrequency capture.""" @@ -571,6 +590,40 @@ def debug_opensearch_response(self) -> dict[str, Any] | None: return None +class Keyword(BaseModel): + """ + Model for user-entered keywords that can be associated with datasets. + Keywords can be associated with multiple datasets via ManyToManyField. + + The name field is auto-slugified and serves as the primary key, + enforcing uniqueness and immutability after creation. + """ + + # Override the uuid primary key from BaseModel + uuid = models.UUIDField(default=uuid.uuid4, editable=False) + + name = KeywordNameField( + max_length=255, + primary_key=True, + help_text=( + "The keyword slug (auto-slugified, e.g., 'RF Spectrum' → 'rf-spectrum')" + ), + ) + datasets = models.ManyToManyField( + "Dataset", + related_name="keywords", + help_text="The datasets this keyword is associated with", + ) + + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] + ordering = ["name"] + verbose_name = "Keyword" + verbose_name_plural = "Keywords" + + def __str__(self) -> str: + return self.name + + class Dataset(BaseModel): """ Model for datasets defined and created through the API. @@ -578,7 +631,7 @@ class Dataset(BaseModel): Schema Definition: https://github.com/spectrumx/schema-definitions/blob/master/definitions/sds/abstractions/dataset/README.md """ - list_fields = ["authors", "keywords", "institutions"] + list_fields = ["authors", "institutions"] STATUS_CHOICES = [ (DatasetStatus.DRAFT, "Draft"), @@ -604,7 +657,6 @@ class Dataset(BaseModel): doi = models.CharField(max_length=255, blank=True) authors = models.TextField(blank=True) license = models.CharField(max_length=255, blank=True) - keywords = models.TextField(blank=True) institutions = models.TextField(blank=True) release_date = models.DateTimeField(blank=True, null=True) repository = models.URLField(blank=True) diff --git a/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py b/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py index 7a96da66..1831585c 100644 --- a/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py +++ b/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py @@ -10,6 +10,7 @@ class DatasetGetSerializer(serializers.ModelSerializer[Dataset]): authors = serializers.SerializerMethodField() + keywords = serializers.SerializerMethodField() created_at = serializers.DateTimeField(format="%m/%d/%Y %H:%M:%S", read_only=True) is_shared_with_me = serializers.SerializerMethodField() is_owner = serializers.SerializerMethodField() @@ -24,6 +25,10 @@ def get_authors(self, obj): """Return the full authors list using the model's get_authors_display method.""" return obj.get_authors_display() + def get_keywords(self, obj): + """Return a list of keyword names for the dataset.""" + return [kw.name for kw in obj.keywords.filter(is_deleted=False)] + def get_is_shared_with_me(self, obj): """Check if the dataset is shared with the current user.""" request = self.context.get("request") diff --git a/gateway/sds_gateway/api_methods/tests/factories.py b/gateway/sds_gateway/api_methods/tests/factories.py index 10352124..f71d222f 100644 --- a/gateway/sds_gateway/api_methods/tests/factories.py +++ b/gateway/sds_gateway/api_methods/tests/factories.py @@ -20,6 +20,7 @@ from sds_gateway.api_methods.models import Dataset from sds_gateway.api_methods.models import File from sds_gateway.api_methods.models import ItemType +from sds_gateway.api_methods.models import Keyword from sds_gateway.api_methods.models import UserSharePermission from sds_gateway.users.tests.factories import UserFactory @@ -42,7 +43,6 @@ class DatasetFactory(DjangoModelFactory): doi: Random UUID4 for DOI identifier authors: Fixed list of test authors ["John Doe", "Jane Smith"] license: Fixed value "MIT" - keywords: Fixed list ["RF", "capture", "analysis"] institutions: Fixed list ["Example University"] release_date: Random datetime repository: Random URL @@ -55,8 +55,14 @@ class DatasetFactory(DjangoModelFactory): is_deleted: Fixed value False is_public: Fixed value False + Post-generation Hooks: + keywords: Creates Keyword instances after Dataset creation. + Pass keyword names as a list: + DatasetFactory(keywords=["RF", "capture"]) + Pass None to skip keyword creation: DatasetFactory(keywords=None) + Example: - # Create a basic dataset + # Create a basic dataset with default keywords dataset = DatasetFactory() # Create a dataset with custom owner @@ -65,6 +71,12 @@ class DatasetFactory(DjangoModelFactory): # Create a public dataset dataset = DatasetFactory(is_public=True) + + # Create a dataset with custom keywords + dataset = DatasetFactory(keywords=["custom", "keywords"]) + + # Create a dataset without keywords + dataset = DatasetFactory(keywords=None) """ uuid = Faker("uuid4") @@ -74,7 +86,6 @@ class DatasetFactory(DjangoModelFactory): doi = Faker("uuid4") authors = ["John Doe", "Jane Smith"] license = "MIT" - keywords = ["RF", "capture", "analysis"] institutions = ["Example University"] release_date = Faker("date_time") repository = Faker("url") @@ -87,6 +98,46 @@ class DatasetFactory(DjangoModelFactory): is_deleted = False is_public = False + @post_generation + def keywords(self, create, extracted, **kwargs): + """Create Keyword instances for the dataset after creation. + + This post-generation hook creates Keyword objects associated with this + dataset. By default, it creates three keywords: "RF", "capture", "analysis". + + Args: + create: Boolean indicating if the object is being created + extracted: Value passed to the factory for keywords parameter. + If a list is provided, those keyword names will be used. + If None is provided, no keywords will be created. + If not provided, default keywords will be created. + **kwargs: Additional keyword arguments + + Example: + # Default keywords + dataset = DatasetFactory() # Creates "RF", "capture", "analysis" + + # Custom keywords + dataset = DatasetFactory(keywords=["custom", "test"]) + + # No keywords + dataset = DatasetFactory(keywords=None) + """ + if not create: + return + + # If extracted is None, skip keyword creation entirely + if extracted is None: + return + + # Use extracted keywords or default keywords + keyword_names = extracted or ["RF", "capture", "analysis"] + + # Create Keyword instances + for keyword_name in keyword_names: + keyword, _created = Keyword.objects.get_or_create(name=keyword_name) + keyword.datasets.add(self) + class Meta: model = Dataset diff --git a/gateway/sds_gateway/static/js/actions/DetailsActionManager.js b/gateway/sds_gateway/static/js/actions/DetailsActionManager.js index 02202532..73fd4763 100644 --- a/gateway/sds_gateway/static/js/actions/DetailsActionManager.js +++ b/gateway/sds_gateway/static/js/actions/DetailsActionManager.js @@ -203,6 +203,9 @@ class DetailsActionManager { // Update authors this.updateAuthors(modal, datasetData.authors || []); + // Update keywords + this.updateKeywords(modal, datasetData.keywords || []); + // Update statistics if (statistics) { this.updateElementText( @@ -442,6 +445,33 @@ class DetailsActionManager { authorsContainer.textContent = authorsText; } + /** + * Update keywords section + * @param {Element} modal - Modal element + * @param {Array} keywords - Keywords array + */ + updateKeywords(modal, keywords) { + const keywordsContainer = modal.querySelector(".dataset-details-keywords"); + if (!keywordsContainer) return; + + // Clear existing content + keywordsContainer.innerHTML = ""; + + if (!keywords || keywords.length === 0) { + keywordsContainer.innerHTML = + 'No keywords'; + return; + } + + // Create badges for each keyword + for (const keyword of keywords) { + const badge = document.createElement("span"); + badge.className = "badge bg-secondary me-2 mb-2"; + badge.textContent = keyword; + keywordsContainer.appendChild(badge); + } + } + /** * Update captures table * @param {Element} modal - Modal element diff --git a/gateway/sds_gateway/static/js/deprecated/captureGroupingComponents.js b/gateway/sds_gateway/static/js/deprecated/captureGroupingComponents.js index 4aef04ab..17a6535a 100644 --- a/gateway/sds_gateway/static/js/deprecated/captureGroupingComponents.js +++ b/gateway/sds_gateway/static/js/deprecated/captureGroupingComponents.js @@ -128,6 +128,7 @@ class FormHandler { show(container, showClass = "display-block") { container.classList.remove("display-none"); + container.classList.remove("d-none"); // ensure Bootstrap's d-none is removed container.classList.add(showClass); } @@ -178,6 +179,9 @@ class FormHandler { document.querySelector("#step4 .dataset-description").textContent = document.getElementById("id_description").value || "No description provided."; + const keywordsInput = document.getElementById("id_keywords"); + document.querySelector("#step4 .dataset-keywords").textContent = + keywordsInput?.value.trim() || "No keywords"; // Update captures table const capturesTableBody = document.querySelector( @@ -466,6 +470,7 @@ class FormHandler { method: "POST", body: formData, headers: { + "X-Requested-With": "XMLHttpRequest", "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]") .value, }, diff --git a/gateway/sds_gateway/static/js/deprecated/datasetDetailsModal.js b/gateway/sds_gateway/static/js/deprecated/datasetDetailsModal.js index d7df56ec..cb07c774 100644 --- a/gateway/sds_gateway/static/js/deprecated/datasetDetailsModal.js +++ b/gateway/sds_gateway/static/js/deprecated/datasetDetailsModal.js @@ -123,6 +123,22 @@ class DatasetDetailsModal { document.querySelector(".dataset-details-description").textContent = dataset.description || "No description available"; + // Populate keywords + const keywordsContainer = document.querySelector( + ".dataset-details-keywords", + ); + if (dataset.keywords && dataset.keywords.length > 0) { + keywordsContainer.innerHTML = dataset.keywords + .map( + (keyword) => + `${this.escapeHtml(keyword)}`, + ) + .join(""); + } else { + keywordsContainer.innerHTML = + 'No keywords'; + } + // Format status with badge using database values const statusElement = document.querySelector(".dataset-details-status"); if (dataset.status === "draft") { diff --git a/gateway/sds_gateway/templates/users/group_captures.html b/gateway/sds_gateway/templates/users/group_captures.html index dc412d65..9eafd82c 100644 --- a/gateway/sds_gateway/templates/users/group_captures.html +++ b/gateway/sds_gateway/templates/users/group_captures.html @@ -120,6 +120,177 @@
{# djlint:off #}