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
23 changes: 23 additions & 0 deletions dandiapi/api/migrations/0031_version_release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2025-11-14 20:26
from __future__ import annotations

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
('api', '0030_alter_asset_path'),
]

operations = [
migrations.AddField(
model_name='version',
name='release_notes',
field=models.CharField(
blank=True,
default='',
help_text='The most recent release notes used for publishing.',
max_length=5000,
),
),
]
8 changes: 8 additions & 0 deletions dandiapi/api/models/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ class Status(models.TextChoices):
validators=[RegexValidator(f'^{VERSION_REGEX}$')],
)
doi = models.CharField(max_length=64, null=True, default=None, blank=True) # noqa: DJ001
release_notes = models.CharField(
max_length=5000,
default='',
blank=True,
help_text='The most recent release notes used for publishing.',
)

"""Track the validation status of this version, without considering assets"""
status = models.CharField(
max_length=10,
Expand Down Expand Up @@ -190,6 +197,7 @@ def strip_metadata(cls, metadata):
'datePublished',
'publishedBy',
'manifestLocation',
'releaseNotes',
]
stripped = {key: metadata[key] for key in metadata if key not in computed_fields}

Expand Down
12 changes: 11 additions & 1 deletion dandiapi/api/services/publish/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,15 @@ def _build_publishable_version_from_draft(draft_version: Version) -> Version:
}
)

release_notes: str | None = draft_version.release_notes
if release_notes:
publishable_version_metadata['releaseNotes'] = release_notes

return Version(
dandiset=draft_version.dandiset,
name=draft_version.name,
metadata=publishable_version_metadata,
release_notes=draft_version.release_notes,
status=Version.Status.VALID,
version=Version.next_published_version(draft_version.dandiset),
)
Expand Down Expand Up @@ -207,9 +212,14 @@ def _create_doi(version_id: int):
)


def publish_dandiset(*, user: User, dandiset: Dandiset) -> None:
def publish_dandiset(*, user: User, dandiset: Dandiset, release_notes: str | None = None) -> None:
from dandiapi.api.tasks import publish_dandiset_task

with transaction.atomic():
_lock_dandiset_for_publishing(user=user, dandiset=dandiset)

Version.objects.filter(dandiset=dandiset, version='draft').update(
release_notes=release_notes or ''
)

transaction.on_commit(lambda: publish_dandiset_task.delay(dandiset.id, user.id))
4 changes: 4 additions & 0 deletions dandiapi/api/tests/test_dandiset.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ def test_dandiset_rest_create(api_client):
'status': 'Pending',
'created': TIMESTAMP_RE,
'modified': TIMESTAMP_RE,
'release_notes': '',
},
'most_recent_published_version': None,
}
Expand Down Expand Up @@ -512,6 +513,7 @@ def test_dandiset_rest_create_with_identifier(api_client):
'status': 'Pending',
'created': TIMESTAMP_RE,
'modified': TIMESTAMP_RE,
'release_notes': '',
},
'contact_person': 'Doe, John',
'embargo_status': 'OPEN',
Expand Down Expand Up @@ -609,6 +611,7 @@ def test_dandiset_rest_create_with_contributor(api_client):
'status': 'Pending',
'created': TIMESTAMP_RE,
'modified': TIMESTAMP_RE,
'release_notes': '',
},
'contact_person': 'Jane Doe',
'embargo_status': 'OPEN',
Expand Down Expand Up @@ -691,6 +694,7 @@ def test_dandiset_rest_create_embargoed(api_client):
'status': 'Pending',
'created': TIMESTAMP_RE,
'modified': TIMESTAMP_RE,
'release_notes': '',
},
'most_recent_published_version': None,
}
Expand Down
151 changes: 142 additions & 9 deletions dandiapi/api/tests/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,24 @@
from freezegun import freeze_time
import pytest

from dandiapi.api import tasks
from dandiapi.api.asset_paths import add_version_asset_paths
from dandiapi.api.models import Asset, Version
from dandiapi.api.models.dandiset import Dandiset
from dandiapi.api.services.metadata import version_aggregate_assets_summary
from dandiapi.api.services.metadata import (
validate_asset_metadata,
validate_version_metadata,
version_aggregate_assets_summary,
)
from dandiapi.api.services.metadata.exceptions import VersionMetadataConcurrentlyModifiedError
from dandiapi.api.services.publish import _build_publishable_version_from_draft, publish_dandiset
from dandiapi.api.tests.factories import (
DandisetFactory,
DraftAssetFactory,
DraftVersionFactory,
PublishedVersionFactory,
UserFactory,
)

if TYPE_CHECKING:
from rest_framework.test import APIClient

from dandiapi.api import tasks
from dandiapi.api.asset_paths import add_version_asset_paths
from dandiapi.api.models import Asset, Version
from dandiapi.api.services.publish import _build_publishable_version_from_draft
from dandiapi.zarr.tasks import ingest_zarr_archive

from .fuzzy import (
Expand All @@ -39,6 +40,9 @@
VERSION_ID_RE,
)

if TYPE_CHECKING:
from rest_framework.test import APIClient

_SCHEMA_CONFIG = get_instance_config()


Expand Down Expand Up @@ -448,6 +452,7 @@ def test_version_rest_list(api_client, version):
'active_uploads': 0,
'size': 0,
'status': version.status,
'release_notes': '',
}
],
}
Expand Down Expand Up @@ -492,6 +497,7 @@ def test_version_rest_info(api_client, version):
'asset_validation_errors': [],
'version_validation_errors': [],
'contact_person': version.metadata['contributor'][0]['name'],
'release_notes': '',
}


Expand Down Expand Up @@ -543,6 +549,7 @@ def test_version_rest_info_with_asset(api_client, draft_asset_factory, asset_sta
'asset_validation_errors': expected_validation_errors,
'version_validation_errors': [],
'contact_person': version.metadata['contributor'][0]['name'],
'release_notes': '',
}


Expand Down Expand Up @@ -622,6 +629,7 @@ def test_version_rest_update(api_client):
'asset_validation_errors': [],
'version_validation_errors': [],
'contact_person': 'Vargas, Getúlio',
'release_notes': '',
}

# The version modified date should be updated
Expand Down Expand Up @@ -1027,3 +1035,128 @@ def test_version_rest_delete_draft_admin(api_client):
assert response.status_code == 403
assert response.data == 'Cannot delete draft versions'
assert draft_version in Version.objects.all()


# Release Notes Tests


@pytest.mark.django_db
def test_version_publish_with_release_notes(api_client: APIClient, draft_asset_factory):
"""Test publishing a dandiset with release notes."""
user = UserFactory.create()
draft_version: Version = DraftVersionFactory.create(dandiset__owners=[user])
api_client.force_authenticate(user=user)

asset: Asset = draft_asset_factory()
draft_version.assets.add(asset)

# Validate the metadata to mark the assets and version as `VALID`
tasks.validate_asset_metadata_task(asset.id)
tasks.validate_version_metadata_task(draft_version.id)
draft_version.refresh_from_db()
assert draft_version.publishable

release_notes = 'This release includes important bug fixes and new features.'
resp = api_client.post(
f'/api/dandisets/{draft_version.dandiset.identifier}'
f'/versions/{draft_version.version}/publish/',
{'release_notes': release_notes},
)
assert resp.status_code == 202

# Wait for publishing to complete (simulate the async task)
draft_version.refresh_from_db()
assert draft_version.status == Version.Status.PUBLISHING

# Run the publish task directly
tasks.publish_dandiset_task(draft_version.dandiset.id, user.id)

# Get the published version
published_version = (
Version.objects.filter(dandiset=draft_version.dandiset).exclude(version='draft').first()
)

assert published_version is not None
assert published_version.metadata.get('releaseNotes') == release_notes


@pytest.mark.django_db
def test_version_build_publishable_with_release_notes():
"""Test that _build_publishable_version_from_draft includes release notes."""
release_notes = 'Major update with new data.'
draft_version: Version = DraftVersionFactory.create(release_notes=release_notes)
published_version = _build_publishable_version_from_draft(draft_version)

assert published_version.metadata['releaseNotes'] == release_notes
assert 'publishedBy' in published_version.metadata
assert 'datePublished' in published_version.metadata


@pytest.mark.django_db
def test_version_build_publishable_without_release_notes():
"""Test that _build_publishable_version_from_draft works without release notes."""
draft_version: Version = DraftVersionFactory.create()
published_version = _build_publishable_version_from_draft(draft_version)

assert 'releaseNotes' not in published_version.metadata
assert 'publishedBy' in published_version.metadata
assert 'datePublished' in published_version.metadata


@pytest.mark.django_db
def test_version_serializer_includes_release_notes():
"""Test that VersionSerializer includes release_notes when present in metadata."""
from dandiapi.api.views.serializers import VersionSerializer

# Create a published version with release notes in metadata
version = PublishedVersionFactory(release_notes='Test release notes')
version.metadata['releaseNotes'] = version.release_notes
version.save()

serializer = VersionSerializer(version)
assert 'release_notes' in serializer.data
assert serializer.data['release_notes'] == 'Test release notes'


# Set transaction=True so that transaction.on_commit() works in publish_dandiset
@pytest.mark.django_db(transaction=True)
def test_version_rest_list_with_release_notes(api_client: APIClient):
"""Test that versions list endpoint includes release_notes when present."""
# Create a publishable draft version
user = UserFactory.create()
draft_version: Version = DraftVersionFactory.create(dandiset__owners=[user])
asset: Asset = DraftAssetFactory()
draft_version.assets.add(asset)
validate_asset_metadata(asset=asset)
validate_version_metadata(version=draft_version)

# Publish the draft w/ release notes
release_notes = 'Important updates'
publish_dandiset(user=user, dandiset=draft_version.dandiset, release_notes=release_notes)

# Ensure the version list endpoint contains the new release notes
response = api_client.get(f'/api/dandisets/{draft_version.dandiset.identifier}/versions/')

assert response.status_code == 200
results = response.data['results']

# Find the published version in results
published_result = next((r for r in results if r['version'] != draft_version.version), None)
assert published_result is not None
assert published_result['release_notes'] == release_notes

# Find the draft version in results
draft_result = next((r for r in results if r['version'] == 'draft'), None)
assert draft_result is not None
# Ensure draft version contains release notes from latest published version
assert draft_result['release_notes'] == release_notes


@pytest.mark.django_db
def test_draft_version_no_release_notes():
"""Test that draft versions never have release notes."""
draft_version = DraftVersionFactory()

# Ensure draft version doesn't have releaseNotes
assert 'releaseNotes' not in draft_version.metadata
assert draft_version.version == 'draft'
5 changes: 5 additions & 0 deletions dandiapi/api/views/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ def validate(self, data):
return super().validate(data)


class PublishVersionSerializer(serializers.Serializer):
release_notes = serializers.CharField(required=False, allow_blank=True, max_length=5000)


class VersionSerializer(serializers.ModelSerializer):
class Meta:
model = Version
Expand All @@ -162,6 +166,7 @@ class Meta:
'created',
'modified',
'dandiset',
'release_notes',
]
read_only_fields = ['created']

Expand Down
12 changes: 9 additions & 3 deletions dandiapi/api/views/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from django.db import transaction
from django_filters import rest_framework as filters
from drf_yasg.utils import no_body, swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import NotAuthenticated, PermissionDenied
Expand All @@ -23,6 +23,7 @@
from dandiapi.api.views.common import DANDISET_PK_PARAM, VERSION_PARAM
from dandiapi.api.views.pagination import DandiPagination
from dandiapi.api.views.serializers import (
PublishVersionSerializer,
VersionDetailSerializer,
VersionMetadataSerializer,
VersionSerializer,
Expand Down Expand Up @@ -135,7 +136,7 @@ def update(self, request, **kwargs):
return Response(serializer.data, status=status.HTTP_200_OK)

@swagger_auto_schema(
request_body=no_body,
request_body=PublishVersionSerializer,
manual_parameters=[DANDISET_PK_PARAM, VERSION_PARAM],
responses={200: VersionSerializer},
)
Expand All @@ -148,7 +149,12 @@ def publish(self, request, **kwargs):
'Only draft versions can be published',
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
publish_dandiset(user=request.user, dandiset=self.get_object().dandiset)
serializer = PublishVersionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
release_notes: str | None = serializer.validated_data.get('release_notes')
publish_dandiset(
user=request.user, dandiset=self.get_object().dandiset, release_notes=release_notes
)
return Response(None, status=status.HTTP_202_ACCEPTED)

@swagger_auto_schema(
Expand Down
6 changes: 4 additions & 2 deletions web/src/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,10 @@ const dandiRest = {
const { data } = await client.get('users/search/', { params: { username } });
return data;
},
async publish(identifier: string): Promise<Version> {
const { data } = await client.post(`dandisets/${identifier}/versions/draft/publish/`);
async publish(identifier: string, releaseNotes?: string): Promise<Version> {
const { data } = await client.post(`dandisets/${identifier}/versions/draft/publish/`, {
release_notes: releaseNotes,
});
return data;
},
async unembargo(identifier: string): Promise<AxiosResponse> {
Expand Down
1 change: 1 addition & 0 deletions web/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface Version {
asset_validation_errors: ValidationError[],
version_validation_errors: ValidationError[],
contact_person?: string,
release_notes?: string | null,
}

export interface Asset {
Expand Down
Loading
Loading