- {{ image(featured_image, "max-900x600", class="img-fluid") }}
+ {% if featured_image is not none or codebase.youtube_url %}
+
+ {% if featured_image is not none %}
+ {{ image(featured_image, "max-900x600", class="img-fluid") }}
+ {% endif %}
{{ vite_asset("apps/image_gallery.ts") }}
{% endif %}
- {% if codebase.associated_publication_text %}
+ {% if codebase.associated_publications %}
+ {% set ns = namespace(has_uncited_publications=false) %}
+ {% for publication in codebase.associated_publications %}
+ {% if not publication.include_in_citation %}
+ {% set ns.has_uncited_publications = true %}
+ {% endif %}
+ {% endfor %}
+ {% if ns.has_uncited_publications %}
{% endif %}
+ {% endif %}
{% if codebase.references_text %}
{% endif %}
+ {% if release.input_data_url %}
+
+ {% endif %}
+
{% if release.output_data_url %}
@@ -620,6 +682,58 @@
{% endscript %}
diff --git a/django/library/metadata.py b/django/library/metadata.py
index 5a482cbcd..5f01c290d 100644
--- a/django/library/metadata.py
+++ b/django/library/metadata.py
@@ -118,6 +118,13 @@ def to_textual_creative_work(cls, text: str) -> dict:
"text": text,
}
+ @classmethod
+ def to_url_creative_work(cls, url: str) -> dict:
+ return {
+ "@type": "CreativeWork",
+ "url": url,
+ }
+
@classmethod
def license_to_creative_work(cls, license) -> dict:
creative_work_license = {
@@ -137,6 +144,7 @@ def url_to_datafeed(cls, url: str) -> dict:
@classmethod
def _common_codebase_fields(cls, codebase) -> dict:
+ associated_publication_links = codebase.associated_publication_links
return dict(
type_="SoftwareSourceCode",
name=codebase.title,
@@ -150,12 +158,16 @@ def _common_codebase_fields(cls, codebase) -> dict:
]
if text
]
+ + [
+ cls.to_url_creative_work(doi)
+ for doi in codebase.citable_associated_publication_links
+ ]
or None,
# tags are sorted so that comparisons are deterministic
keywords=[tag.name for tag in codebase.tags.all().order_by("name")] or None,
publisher=cls.COMSES_ORGANIZATION,
description=codebase.description.raw,
- referencePublication=codebase.associated_publication_text or None,
+ referencePublication=associated_publication_links or None,
)
@classmethod
@@ -196,6 +208,13 @@ def _convert_release_language(cls, release_language) -> dict:
@classmethod
def _convert_release(cls, release) -> CodeMeta:
codebase = release.codebase
+ supporting = []
+ # prefer listing input data first, then output data
+ if getattr(release, "input_data_url", None):
+ supporting.append(cls.url_to_datafeed(release.input_data_url))
+ if release.output_data_url:
+ supporting.append(cls.url_to_datafeed(release.output_data_url))
+
return CodeMeta(
**cls._common_codebase_fields(codebase),
id_=release.permanent_url,
@@ -218,11 +237,7 @@ def _convert_release(cls, release) -> CodeMeta:
downloadUrl=f"{settings.BASE_URL}{release.get_download_url()}",
operatingSystem=release.os,
releaseNotes=release.release_notes.raw,
- supportingData=(
- cls.url_to_datafeed(release.output_data_url)
- if release.output_data_url
- else None
- ),
+ supportingData=supporting or None,
# FIXME: need better guidance on author vs contributor fields in CodeMeta
author=cls.convert_contributors(
release.author_release_contributors, "author"
diff --git a/django/library/migrations/0038_codebase_associated_publications_and_more.py b/django/library/migrations/0038_codebase_associated_publications_and_more.py
new file mode 100644
index 000000000..677b770a4
--- /dev/null
+++ b/django/library/migrations/0038_codebase_associated_publications_and_more.py
@@ -0,0 +1,45 @@
+# Generated by Django 5.2.11 on 2026-05-26 16:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("library", "0037_initial_github_integration_overview_content"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="codebase",
+ name="associated_publications",
+ field=models.JSONField(
+ blank=True,
+ default=list,
+ help_text="List of DOI links associated with this codebase. Each item should include a DOI link and whether it should appear in the citation area.",
+ ),
+ ),
+ migrations.AddField(
+ model_name="codebase",
+ name="preferred_citation",
+ field=models.TextField(
+ blank=True,
+ help_text="Legacy preferred citation text. Use associated_publications to control what appears in the citation area.",
+ ),
+ ),
+ migrations.AddField(
+ model_name="codebase",
+ name="youtube_url",
+ field=models.URLField(
+ blank=True,
+ help_text="Optional YouTube URL showcasing this codebase (e.g., a demo or tutorial video).",
+ ),
+ ),
+ migrations.AddField(
+ model_name="codebaserelease",
+ name="input_data_url",
+ field=models.URLField(
+ blank=True, help_text="Permanent URL to input data used by this model."
+ ),
+ ),
+ ]
diff --git a/django/library/models.py b/django/library/models.py
index 500936412..e91845c77 100644
--- a/django/library/models.py
+++ b/django/library/models.py
@@ -1089,6 +1089,12 @@ class Codebase(index.Indexed, ModeratedContent, ClusterableModel):
"URL to code repository, e.g., https://github.com/comses/wolf-sheep"
),
)
+ youtube_url = models.URLField(
+ blank=True,
+ help_text=_(
+ "Optional YouTube URL showcasing this codebase (e.g., a demo or tutorial video)."
+ ),
+ )
replication_text = models.TextField(
blank=True,
help_text=_("URL / DOI / citation for the original model being replicated"),
@@ -1106,6 +1112,19 @@ class Codebase(index.Indexed, ModeratedContent, ClusterableModel):
"DOI, Permanent URL, or citation to a publication associated with this codebase."
),
)
+ preferred_citation = models.TextField(
+ blank=True,
+ help_text=_(
+ "Legacy preferred citation text. Use associated_publications to control what appears in the citation area."
+ ),
+ )
+ associated_publications = models.JSONField(
+ default=list,
+ blank=True,
+ help_text=_(
+ "List of DOI links associated with this codebase. Each item should include a DOI link and whether it should appear in the citation area."
+ ),
+ )
tags = ClusterTaggableManager(through=CodebaseTag)
# evaluate this JSONField as an add-anything way to record relationships between this Codebase and other entities
# with URLs / resolvable identifiers
@@ -1317,6 +1336,27 @@ def publication_year(self):
self.last_published_on.year if self.last_published_on else date.today().year
)
+ @staticmethod
+ def _publication_doi(publication: dict) -> str:
+ return (publication.get("doi") or "").strip()
+
+ @property
+ def associated_publication_links(self):
+ return [
+ doi
+ for publication in self.associated_publications
+ if (doi := self._publication_doi(publication))
+ ]
+
+ @property
+ def citable_associated_publication_links(self):
+ return [
+ doi
+ for publication in self.associated_publications
+ if publication.get("include_in_citation")
+ and (doi := self._publication_doi(publication))
+ ]
+
@property
def all_contributors(self):
"""
@@ -1529,7 +1569,6 @@ def get_or_create_draft(self, initial_version: str | None = None):
# reset fields that should not be copied over to a new draft
draft_release.doi = None
draft_release.release_notes = ""
- draft_release.output_data_url = ""
draft_release.save()
return draft_release
@@ -1856,6 +1895,9 @@ class Status(models.TextChoices):
output_data_url = models.URLField(
blank=True, help_text=_("Permanent URL to output data from this model.")
)
+ input_data_url = models.URLField(
+ blank=True, help_text=_("Permanent URL to input data used by this model.")
+ )
version_number = models.CharField(
max_length=32, help_text=_("semver string, e.g., 1.0.5, see semver.org")
)
@@ -3378,10 +3420,9 @@ def __init__(self, release: CodebaseRelease):
release.citation_text,
codebase.references_text,
codebase.replication_text,
- codebase.associated_publication_text,
]
if text
- ]
+ ] + codebase.citable_associated_publication_links
if release.live:
self.first_published = release.first_published_at.date()
@@ -3737,7 +3778,7 @@ def convert(cls, codebase: Codebase):
```
"""
# FIXME: establish CommonMetadata for Codebases as well and change signature to operate on CommonMetadata
- # add references_text and associated_publication_text fields when better structured metadata for those fields are available
+ # add references_text and associated_publications fields when better structured metadata for those fields are available
metadata = {
"creators": cls.to_citable_authors(codebase.all_author_contributors),
"titles": [{"title": codebase.title}],
diff --git a/django/library/permissions.py b/django/library/permissions.py
index 4dede205e..905019378 100644
--- a/django/library/permissions.py
+++ b/django/library/permissions.py
@@ -8,14 +8,14 @@
class GitHubIntegrationPermission(BasePermission):
"""Permission class to check if user can use GitHub integration."""
-
+
def has_permission(self, request, view):
try:
site = Site.objects.get(is_default_site=True)
config = GitHubIntegrationConfiguration.for_site(site)
except (Site.DoesNotExist, GitHubIntegrationConfiguration.DoesNotExist):
raise DrfPermissionDenied("GitHub integration is not available")
-
+
if not config.can_use_github_integration(request.user):
raise DrfPermissionDenied("GitHub integration is not available")
return True
diff --git a/django/library/serializers.py b/django/library/serializers.py
index 75efb470a..2d6629a07 100644
--- a/django/library/serializers.py
+++ b/django/library/serializers.py
@@ -1,5 +1,7 @@
import logging
+import re
from collections import defaultdict
+from urllib.parse import urlparse
from django.db import models
from django.contrib.auth.models import User
@@ -50,6 +52,38 @@
logger = logging.getLogger(__name__)
+DOI_LINK_PATTERN = re.compile(
+ r"^(?:https?://(?:dx\.)?doi\.org/)?10\.\d{4,9}/[-._;()/:A-Z0-9]+$",
+ re.IGNORECASE,
+)
+
+
+def normalize_doi_link(value: str) -> str:
+ value = (value or "").strip()
+ if not value:
+ return value
+ if value.lower().startswith("doi:"):
+ value = value[4:].strip()
+ if value.lower().startswith("http://") or value.lower().startswith("https://"):
+ parsed = urlparse(value)
+ if parsed.netloc.endswith("doi.org") and parsed.path:
+ value = parsed.path.lstrip("/")
+ if not DOI_LINK_PATTERN.match(value):
+ raise ValidationError(_(f"Invalid DOI link: {value}"))
+ if value.lower().startswith("http://") or value.lower().startswith("https://"):
+ parsed = urlparse(value)
+ if parsed.netloc.endswith("doi.org") and parsed.path:
+ return f"https://doi.org/{parsed.path.lstrip('/')}"
+ return f"https://doi.org/{value}"
+
+
+class AssociatedPublicationSerializer(serializers.Serializer):
+ doi = serializers.CharField()
+ include_in_citation = serializers.BooleanField(default=False)
+
+ def validate_doi(self, value):
+ return normalize_doi_link(value)
+
class LicenseSerializer(serializers.ModelSerializer):
def validate_name(self, value):
@@ -408,6 +442,7 @@ class CodebaseSerializer(
summarized_description = serializers.CharField(read_only=True)
identifier = serializers.ReadOnlyField()
tags = TagSerializer(many=True)
+ associated_publications = AssociatedPublicationSerializer(many=True, required=False)
description = MarkdownField()
@@ -436,6 +471,22 @@ def create(self, validated_data):
def update(self, instance, validated_data):
return update(super().update, instance, validated_data)
+ def validate_associated_publications(self, value):
+ seen = set()
+ cleaned = []
+ for publication in value:
+ doi = publication["doi"]
+ if doi in seen:
+ raise ValidationError(_("Duplicate DOI links are not allowed."))
+ seen.add(doi)
+ cleaned.append(
+ {
+ "doi": doi,
+ "include_in_citation": bool(publication.get("include_in_citation")),
+ }
+ )
+ return cleaned
+
class Meta:
model = Codebase
fields = (
@@ -445,6 +496,7 @@ class Meta:
"download_count",
"featured_image",
"repository_url",
+ "youtube_url",
"first_published_at",
"last_published_on",
"latest_version_number",
@@ -458,7 +510,7 @@ class Meta:
"identifier",
"id",
"references_text",
- "associated_publication_text",
+ "associated_publications",
"replication_text",
"peer_reviewed",
)
@@ -519,6 +571,7 @@ class Meta:
"live",
"peer_reviewed",
"repository_url",
+ "youtube_url",
)
@@ -710,6 +763,7 @@ class Meta:
"codebase",
"review_status",
"output_data_url",
+ "input_data_url",
"version_number",
"id",
"imported_release_sync_state",
diff --git a/e2e/cypress/tests/codebase.spec.ts b/e2e/cypress/tests/codebase.spec.ts
index 322fec3a5..7a60f3632 100644
--- a/e2e/cypress/tests/codebase.spec.ts
+++ b/e2e/cypress/tests/codebase.spec.ts
@@ -50,8 +50,8 @@ describe("Visit codebases page", () => {
getDataCy("next").click();
// make sure the release editor is initialized
cy.wait(2000);
- //add images
- getDataCy("add-image").click();
+ // add media (images + optional YouTube)
+ getDataCy("add-media").click();
getDataCy("upload-image")
.first()
.selectFile("cypress/fixtures/codebase/codebasetestimage.png", { force: true });
diff --git a/frontend/src/apps/image_gallery.ts b/frontend/src/apps/image_gallery.ts
index 812826c28..b279d9578 100644
--- a/frontend/src/apps/image_gallery.ts
+++ b/frontend/src/apps/image_gallery.ts
@@ -4,6 +4,6 @@ import { createApp } from "vue";
import ImageGallery from "@/components/ImageGallery.vue";
import { extractDataParams } from "@/util";
-const props = extractDataParams("image-gallery", ["images", "title"]);
+const props = extractDataParams("image-gallery", ["images", "title", "videoUrl"]);
createApp(ImageGallery, props).mount("#image-gallery");
diff --git a/frontend/src/components/CodebaseEditForm.vue b/frontend/src/components/CodebaseEditForm.vue
index 644f298f2..47cab376f 100644
--- a/frontend/src/components/CodebaseEditForm.vue
+++ b/frontend/src/components/CodebaseEditForm.vue
@@ -8,7 +8,7 @@
data-cy="codebase-title"
required
/>
-
+
-
+
+
+
+ Video
+
+
+
+
+