Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions gateway/sds_gateway/api_methods/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]])
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0017_convert_dataset_authors_to_object_format
0021_remove_old_dataset_keywords_table
56 changes: 54 additions & 2 deletions gateway/sds_gateway/api_methods/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""

Expand Down Expand Up @@ -571,14 +590,48 @@ 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.

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"),
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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")
Expand Down
57 changes: 54 additions & 3 deletions gateway/sds_gateway/api_methods/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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

Expand Down
30 changes: 30 additions & 0 deletions gateway/sds_gateway/static/js/actions/DetailsActionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 =
'<span class="text-muted">No keywords</span>';
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -466,6 +470,7 @@ class FormHandler {
method: "POST",
body: formData,
headers: {
"X-Requested-With": "XMLHttpRequest",
"X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]")
.value,
},
Expand Down
16 changes: 16 additions & 0 deletions gateway/sds_gateway/static/js/deprecated/datasetDetailsModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
`<span class="badge bg-secondary me-1 mb-1">${this.escapeHtml(keyword)}</span>`,
)
.join("");
} else {
keywordsContainer.innerHTML =
'<span class="text-muted">No keywords</span>';
}

// Format status with badge using database values
const statusElement = document.querySelector(".dataset-details-status");
if (dataset.status === "draft") {
Expand Down
Loading