From 4a464a2c3d936126e6529e25e6bdb7c0b6bffa42 Mon Sep 17 00:00:00 2001 From: Miles Wells Date: Thu, 25 Jun 2026 15:14:36 +0300 Subject: [PATCH 1/2] Data Notice model for information about datasets --- alyx/alyx/base.py | 2 +- alyx/data/admin.py | 36 +++++- alyx/data/migrations/0023_datanotice.py | 37 +++++++ alyx/data/models.py | 61 +++++++++++ alyx/data/serializers.py | 52 ++++++++- alyx/data/tests.py | 19 +++- alyx/data/tests_rest.py | 140 +++++++++++++++++++++++- alyx/data/urls.py | 6 + alyx/data/views.py | 67 +++++++++++- alyx/templates/base.html | 2 +- 10 files changed, 413 insertions(+), 9 deletions(-) create mode 100644 alyx/data/migrations/0023_datanotice.py diff --git a/alyx/alyx/base.py b/alyx/alyx/base.py index c08723c71..7aab4d9e9 100644 --- a/alyx/alyx/base.py +++ b/alyx/alyx/base.py @@ -194,7 +194,7 @@ def alyx_mail(to, subject, text=''): 'Imaging sessions', 'Fields of view']), # May add 'Imaging type' ('Data files', [ 'Data repository types', 'Data repositories', 'Tasks', 'Data formats', 'Dataset types', - 'Datasets', 'File records', 'Tags', 'Revisions', 'Downloads']), + 'Datasets', 'File records', 'Tags', 'Revisions', 'Data notices', 'Downloads']), ('Subject genetics', [ 'Lines', 'Strains', 'Alleles', 'Sequences', 'Sources', 'Species', 'Genotypes', 'Genotype tests', 'Zygosities', 'Zygosity tests']), diff --git a/alyx/data/admin.py b/alyx/data/admin.py index f8d503f1d..e9e59b8d7 100644 --- a/alyx/data/admin.py +++ b/alyx/data/admin.py @@ -5,7 +5,7 @@ from rangefilter.filters import DateRangeFilter from .models import (DataRepositoryType, DataRepository, DataFormat, DatasetType, - Dataset, FileRecord, Download, Revision, Tag) + Dataset, FileRecord, Download, Revision, Tag, DataNotice) from alyx.base import BaseAdmin, BaseInlineAdmin, DefaultListFilter, get_admin_url @@ -231,6 +231,39 @@ def get_queryset(self, request): return queryset +class DataNoticeAdmin(BaseAdmin): + fields = ( + 'name', + 'description', + 'importance', + 'version_affected', + 'affected_date_start', + 'affected_date_end', + 'datasets', + 'created_by', + 'json', + ) + readonly_fields = ('created_datetime',) + + def has_change_permission(self, request, obj=None): + # DataNotice has no subject/session ownership; any non-public authenticated user may edit. + if request.user.is_public_user: + return False + return True + + list_display = ( + 'name', + 'importance', + 'version_affected', + 'affected_date_start', + 'affected_date_end', + 'created_by', + 'created_datetime', + ) + search_fields = ('name', 'description', 'version_affected', 'created_by__username') + autocomplete_fields = ('datasets',) + + admin.site.register(DataRepositoryType, DataRepositoryTypeAdmin) admin.site.register(DataRepository, DataRepositoryAdmin) admin.site.register(DataFormat, DataFormatAdmin) @@ -240,3 +273,4 @@ def get_queryset(self, request): admin.site.register(Download, DownloadAdmin) admin.site.register(Revision, RevisionAdmin) admin.site.register(Tag, TagAdmin) +admin.site.register(DataNotice, DataNoticeAdmin) diff --git a/alyx/data/migrations/0023_datanotice.py b/alyx/data/migrations/0023_datanotice.py new file mode 100644 index 000000000..125a9edcf --- /dev/null +++ b/alyx/data/migrations/0023_datanotice.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.12 on 2026-06-22 11:27 + +import data.models +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0022_alter_datarepository_timezone'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DataNotice', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(blank=True, help_text='Long name', max_length=255)), + ('json', models.JSONField(blank=True, help_text='Structured data, formatted in a user-defined way', null=True)), + ('description', models.TextField(blank=True)), + ('importance', models.IntegerField(choices=[(50, 'CRITICAL'), (40, 'MAJOR'), (30, 'MINOR'), (20, 'INSIGNIFICANT')], default=data.models.DataNotice.IMPORTANCE['INSIGNIFICANT'], help_text='50: CRITICAL / 40: MAJOR / 30: MINOR / 20: INSIGNIFICANT')), + ('created_datetime', models.DateTimeField(auto_now_add=True)), + ('version_affected', models.CharField(blank=True, max_length=64)), + ('affected_date_start', models.DateField(blank=True, null=True)), + ('affected_date_end', models.DateField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='data_notices', to=settings.AUTH_USER_MODEL)), + ('datasets', models.ManyToManyField(blank=True, related_name='data_notices', to='data.dataset')), + ], + options={ + 'ordering': ('-created_datetime', '-severity', 'name'), + }, + ), + ] diff --git a/alyx/data/models.py b/alyx/data/models.py index 72dd38e8e..ec1a0cc2d 100644 --- a/alyx/data/models.py +++ b/alyx/data/models.py @@ -1,4 +1,6 @@ import logging +import markdown as _markdown +from enum import IntEnum from one.alf.spec import QC from django.core.validators import RegexValidator @@ -498,3 +500,62 @@ def new_download(dataset, user, projects=()): d.projects.add(*projects) d.increment() return d + + +class DataNotice(BaseModel): + """A notice about data quality issues that may affect one or more datasets.""" + + class IMPORTANCE(IntEnum): + CRITICAL = 50 + MAJOR = 40 + MINOR = 30 + INSIGNIFICANT = 20 + + description = models.TextField(blank=True) + importance = models.IntegerField( + default=IMPORTANCE.INSIGNIFICANT, choices=[(x.value, x.name) for x in IMPORTANCE], + help_text=' / '.join([f'{q.value}: {q.name}' for q in IMPORTANCE])) + + datasets = models.ManyToManyField( + Dataset, blank=True, related_name='data_notices') + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='data_notices', + ) + created_datetime = models.DateTimeField(auto_now_add=True) + version_affected = models.CharField(max_length=64, blank=True) + affected_date_start = models.DateField(null=True, blank=True) + affected_date_end = models.DateField(null=True, blank=True) + + def description_html(self): + """Render description as safe HTML via markdown.""" + if not self.description: + return '' + return _markdown.markdown(self.description, extensions=['extra']) + + def importance_panel_class(self): + """Bootstrap panel class for this notice's importance level.""" + return { + self.IMPORTANCE.CRITICAL: 'danger', + self.IMPORTANCE.MAJOR: 'warning', + self.IMPORTANCE.MINOR: 'info', + self.IMPORTANCE.INSIGNIFICANT: 'default', + }.get(self.importance, 'default') + + def importance_badge_color(self): + """Hex color for the importance badge.""" + return { + self.IMPORTANCE.CRITICAL: '#c9302c', + self.IMPORTANCE.MAJOR: '#ec971f', + self.IMPORTANCE.MINOR: '#31b0d5', + self.IMPORTANCE.INSIGNIFICANT: '#6c757d', + }.get(self.importance, '#6c757d') + + class Meta: + ordering = ('-created_datetime', '-importance', 'name') + + def __str__(self): + return self.name or str(self.id) diff --git a/alyx/data/serializers.py b/alyx/data/serializers.py index 57f24c0d6..3d4f886db 100644 --- a/alyx/data/serializers.py +++ b/alyx/data/serializers.py @@ -1,11 +1,12 @@ from django.contrib.auth import get_user_model +from django.db.models import Count, Q, BooleanField, Prefetch from rest_framework import serializers -from django.db.models import Count, Q, BooleanField +import markdown from one.alf.spec import QC from .models import (DataRepositoryType, DataRepository, DataFormat, DatasetType, - Dataset, Download, FileRecord, Revision, Tag) + Dataset, Download, FileRecord, Revision, Tag, DataNotice) from .transfers import _get_session, _change_default_dataset from alyx.base import BaseSerializerEnumField from actions.models import Session @@ -253,3 +254,50 @@ class RevisionSerializer(serializers.ModelSerializer): class Meta: model = Revision fields = '__all__' + + +class DataNoticeSerializer(serializers.ModelSerializer): + created_by = serializers.SlugRelatedField( + slug_field='username', + queryset=get_user_model().objects.all(), + required=False, + allow_null=True, + ) + datasets = serializers.SlugRelatedField( + slug_field='pk', + many=True, + queryset=Dataset.objects.all(), + required=False, + ) + description_html = serializers.SerializerMethodField() + + def get_description_html(self, obj): + """Render description field as HTML using markdown.""" + if not obj.description: + return '' + return markdown.markdown(obj.description, extensions=['extra', 'codehilite']) + + @staticmethod + def setup_eager_loading(queryset): + queryset = queryset.select_related('created_by') + # Prefetch only dataset IDs (serializer uses SlugRelatedField with pk). + # This avoids loading full Dataset objects into memory for list responses. + dataset_prefetch = Prefetch( + 'datasets', + Dataset.objects.only('id') + ) + queryset = queryset.prefetch_related(dataset_prefetch) + return queryset + + class Meta: + model = DataNotice + fields = '__all__' + + def validate(self, attrs): + start = attrs.get('affected_date_start', getattr(self.instance, 'affected_date_start', None)) + end = attrs.get('affected_date_end', getattr(self.instance, 'affected_date_end', None)) + if start and end and end < start: + raise serializers.ValidationError( + {'affected_date_end': 'affected_date_end must be on or after affected_date_start.'} + ) + return attrs diff --git a/alyx/data/tests.py b/alyx/data/tests.py index 754f71bb5..eecd71b22 100644 --- a/alyx/data/tests.py +++ b/alyx/data/tests.py @@ -12,7 +12,7 @@ from one.alf.path import add_uuid_string from data.management.commands import files -from data.models import Dataset, DatasetType, Tag, Revision, DataRepository, FileRecord +from data.models import Dataset, DatasetType, Tag, Revision, DataRepository, FileRecord, DataNotice from subjects.models import Subject from actions.models import Session from misc.models import Lab @@ -93,6 +93,23 @@ def test_validation(self): self.assertRaises(ValidationError, Revision.objects.create, name='#2022-01-01.#') +class TestDataNoticeModel(TestCase): + def test_defaults_and_relations(self): + dataset = Dataset.objects.create(name='mydataset.npy') + notice = DataNotice.objects.create(name='notice-a') + + self.assertEqual(notice.severity, DataNotice.IMPORTANCE.INSIGNIFICANT) + self.assertEqual(str(notice), 'notice-a') + + notice.datasets.add(dataset) + self.assertEqual(notice.datasets.count(), 1) + self.assertEqual(dataset.data_notices.count(), 1) + + def test_str_without_name(self): + notice = DataNotice.objects.create(name='') + self.assertEqual(str(notice), str(notice.id)) + + class TestManagementFiles(TestCase): """Tests for the files management command.""" diff --git a/alyx/data/tests_rest.py b/alyx/data/tests_rest.py index 75bd9fd50..28e8421a8 100644 --- a/alyx/data/tests_rest.py +++ b/alyx/data/tests_rest.py @@ -7,7 +7,7 @@ from django.urls import reverse from alyx.base import BaseTests -from data.models import Dataset, FileRecord, Download, Tag, DatasetType, DataFormat +from data.models import Dataset, FileRecord, Download, Tag, DatasetType, DataFormat, DataNotice from subjects.models import Subject @@ -243,6 +243,144 @@ def test_dataset_date_filter(self): r = self.client.get(reverse('dataset-list') + '?date=2018-01-01') self.assertTrue(len(self.ar(r, 200)) == 2) + def test_datanotice_list(self): + d1 = Dataset.objects.create(name='notice-dataset-1.npy') + d2 = Dataset.objects.create(name='notice-dataset-2.npy') + + n1 = DataNotice.objects.create( + name='notice-1', + severity=DataNotice.IMPORTANCE.MAJOR, + created_by=self.superuser, + ) + n1.datasets.add(d1) + n2 = DataNotice.objects.create( + name='notice-2', + severity=DataNotice.IMPORTANCE.MINOR, + created_by=self.superuser, + ) + n2.datasets.add(d2) + + r = self.client.get(reverse('datanotice-list')) + data = self.ar(r, 200) + names = [item['name'] for item in data] + self.assertEqual(names[:2], ['notice-1', 'notice-2']) + + def test_datanotice_list_filter_by_dataset(self): + d1 = Dataset.objects.create(name='notice-filter-1.npy') + d2 = Dataset.objects.create(name='notice-filter-2.npy') + + n1 = DataNotice.objects.create(name='notice-a', created_by=self.superuser) + n1.datasets.add(d1) + n2 = DataNotice.objects.create(name='notice-b', created_by=self.superuser) + n2.datasets.add(d2) + n3 = DataNotice.objects.create(name='notice-c', created_by=self.superuser) + n3.datasets.add(d1, d2) + + r = self.client.get(reverse('datanotice-list') + f'?datasets={d1.id}') + data = self.ar(r, 200) + names = {item['name'] for item in data} + self.assertEqual(names, {'notice-a', 'notice-c'}) + + r = self.client.get(reverse('datanotice-list') + f'?datasets={d1.id},{d2.id}') + data = self.ar(r, 200) + names = {item['name'] for item in data} + self.assertEqual(names, {'notice-a', 'notice-b', 'notice-c'}) + + def test_datanotice_list_filter_by_dataset_tag(self): + d1 = Dataset.objects.create(name='notice-tag-1.npy') + d2 = Dataset.objects.create(name='notice-tag-2.npy') + public_tag = Tag.objects.create(name='public-tag') + private_tag = Tag.objects.create(name='private-tag') + d1.tags.add(public_tag) + d2.tags.add(private_tag) + + n1 = DataNotice.objects.create(name='tag-notice-a', created_by=self.superuser) + n1.datasets.add(d1) + n2 = DataNotice.objects.create(name='tag-notice-b', created_by=self.superuser) + n2.datasets.add(d2) + n3 = DataNotice.objects.create(name='tag-notice-c', created_by=self.superuser) + n3.datasets.add(d1, d2) + + r = self.client.get(reverse('datanotice-list') + '?dataset_tag=public-tag') + data = self.ar(r, 200) + names = {item['name'] for item in data} + self.assertEqual(names, {'tag-notice-a', 'tag-notice-c'}) + + def test_datanotice_list_ordering_date_importance_name(self): + d = Dataset.objects.create(name='notice-ordering.npy') + + # Same creation timestamp window: higher importance should sort first, then by name. + high_z = DataNotice.objects.create( + name='z-high', + importance=DataNotice.IMPORTANCE.MAJOR, + created_by=self.superuser, + ) + high_z.datasets.add(d) + high_a = DataNotice.objects.create( + name='a-high', + importance=DataNotice.IMPORTANCE.MAJOR, + created_by=self.superuser, + ) + high_a.datasets.add(d) + low = DataNotice.objects.create( + name='m-low', + importance=DataNotice.IMPORTANCE.MINOR, + created_by=self.superuser, + ) + low.datasets.add(d) + + r = self.client.get(reverse('datanotice-list')) + data = self.ar(r, 200) + names = [x['name'] for x in data] + idx_a = names.index('a-high') + idx_z = names.index('z-high') + idx_low = names.index('m-low') + + self.assertLess(idx_a, idx_z) + self.assertLess(idx_z, idx_low) + + def test_datanotice_list_filter_by_dataset_tag_optimization(self): + """Verify dataset_tag filter correctly uses Exists() optimization (no row explosion).""" + # Create multiple datasets with various tags + datasets = [Dataset.objects.create(name=f'opt-test-{i}.npy') for i in range(5)] + target_tag = Tag.objects.create(name='opt-target-tag') + other_tag = Tag.objects.create(name='opt-other-tag') + + # Add tags to datasets + datasets[0].tags.add(target_tag) # has target tag + datasets[1].tags.add(other_tag) # has other tag + datasets[2].tags.add(target_tag, other_tag) # has both + datasets[3].tags.add(other_tag) # has other tag + datasets[4].tags.add(target_tag) # has target tag + + # Create notices with various dataset combinations + n1 = DataNotice.objects.create(name='opt-notice-1', created_by=self.superuser) + n1.datasets.add(*datasets[:3]) # includes datasets 0, 1, 2 + + n2 = DataNotice.objects.create(name='opt-notice-2', created_by=self.superuser) + n2.datasets.add(*datasets[3:]) # includes datasets 3, 4 + + n3 = DataNotice.objects.create(name='opt-notice-3', created_by=self.superuser) + n3.datasets.add(datasets[4]) # only dataset 4 + + # Filter by target_tag; should get notices with datasets having that tag + # n1: datasets[0,1,2] -> includes 0 (has target) and 2 (has target) ✓ + # n2: datasets[3,4] -> includes 4 (has target) ✓ + # n3: datasets[4] -> includes 4 (has target) ✓ + r = self.client.get(reverse('datanotice-list') + '?dataset_tag=opt-target-tag') + data = self.ar(r, 200) + names = {item['name'] for item in data} + self.assertEqual(names, {'opt-notice-1', 'opt-notice-2', 'opt-notice-3'}) + + # Filter by other_tag; should get different results + # n1: datasets[0,1,2] -> includes 1 (has other) and 2 (has other) ✓ + # n2: datasets[3,4] -> includes 3 (has other) ✓ + # n3: datasets[4] -> has no other tag ✗ + r = self.client.get(reverse('datanotice-list') + '?dataset_tag=opt-other-tag') + data = self.ar(r, 200) + names = {item['name'] for item in data} + self.assertEqual(names, {'opt-notice-1', 'opt-notice-2'}) + def test_register_files(self): # create 4 repositories, 2 per lab self.post(reverse('datarepository-list'), {'name': 'dra1', 'hostname': 'hosta1'}) diff --git a/alyx/data/urls.py b/alyx/data/urls.py index daabc89ee..eb54653f8 100644 --- a/alyx/data/urls.py +++ b/alyx/data/urls.py @@ -49,6 +49,12 @@ path('tags/', dv.TagDetail.as_view(), name="tag-detail"), + path('data-notices', dv.DataNoticeList.as_view(), + name='datanotice-list'), + + path('data-notices/', dv.DataNoticeDetail.as_view(), + name='datanotice-detail'), + path('datasets', dv.DatasetList.as_view(), name="dataset-list"), diff --git a/alyx/data/views.py b/alyx/data/views.py index dc67d32f6..3e0134498 100644 --- a/alyx/data/views.py +++ b/alyx/data/views.py @@ -4,9 +4,11 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Exists, OuterRef from rest_framework import generics, viewsets, mixins, serializers from rest_framework.response import Response import django_filters +from django_filters import rest_framework as filters from alyx.base import BaseFilterSet, rest_permission_classes from experiments.models import ProbeInsertion @@ -21,7 +23,8 @@ FileRecord, new_download, Revision, - Tag + Tag, + DataNotice ) from .serializers import (DataRepositoryTypeSerializer, DataRepositorySerializer, @@ -31,7 +34,8 @@ DownloadSerializer, FileRecordSerializer, RevisionSerializer, - TagSerializer + TagSerializer, + DataNoticeSerializer ) from .transfers import (_get_session, _parse_path, _get_repositories_for_labs, bulk_sync, _check_dataset_protected, _get_name_collection_revision, @@ -138,6 +142,65 @@ class TagDetail(generics.RetrieveUpdateDestroyAPIView): permission_classes = rest_permission_classes() lookup_field = 'name' + +class UUIDInFilter(filters.BaseInFilter, filters.UUIDFilter): + """Provides validation of list of UUIDs, passed as comma-separated string.""" + pass + + +class DataNoticeFilter(BaseFilterSet): + datasets = UUIDInFilter(field_name='datasets__id', lookup_expr='in') + dataset_tag = django_filters.CharFilter(method='dataset_tag_filter') + + def dataset_tag_filter(self, notices, name, value): + """Filter notices by dataset tag using Exists semi-join (avoids fan-out).""" + through_model = notices.model.datasets.through + return notices.filter( + Exists( + through_model.objects.filter( + datanotice_id=OuterRef('id'), + dataset__tags__name=value + ) + ) + ) + + class Meta: + model = DataNotice + fields = ( + 'name', + 'importance', + 'created_by', + 'version_affected', + 'affected_date_start', + 'affected_date_end', + 'datasets', + 'dataset_tag', + ) + + +class DataNoticeList(generics.ListCreateAPIView): + queryset = DataNotice.objects.all() + serializer_class = DataNoticeSerializer + permission_classes = rest_permission_classes() + filterset_class = DataNoticeFilter + + def get_queryset(self): + queryset = super().get_queryset() + queryset = self.serializer_class.setup_eager_loading(queryset) + # For list endpoints, defer large fields not needed in list responses + queryset = queryset.defer('description', 'json') + return queryset + + +class DataNoticeDetail(generics.RetrieveUpdateDestroyAPIView): + queryset = DataNotice.objects.all() + serializer_class = DataNoticeSerializer + permission_classes = rest_permission_classes() + + def get_queryset(self): + queryset = super().get_queryset() + return self.serializer_class.setup_eager_loading(queryset) + # Dataset # ------------------------------------------------------------------------------------------------ diff --git a/alyx/templates/base.html b/alyx/templates/base.html index b0b373e99..9fb04403c 100644 --- a/alyx/templates/base.html +++ b/alyx/templates/base.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %} From bd18ba089eebffb1caad0bf126256869c6bcef5a Mon Sep 17 00:00:00 2001 From: Miles Wells Date: Wed, 1 Jul 2026 15:45:06 +0300 Subject: [PATCH 2/2] Fix tests --- alyx/data/serializers.py | 4 +++- alyx/data/tests.py | 2 +- alyx/data/tests_rest.py | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/alyx/data/serializers.py b/alyx/data/serializers.py index 3d4f886db..3647f2350 100644 --- a/alyx/data/serializers.py +++ b/alyx/data/serializers.py @@ -282,9 +282,11 @@ def setup_eager_loading(queryset): queryset = queryset.select_related('created_by') # Prefetch only dataset IDs (serializer uses SlugRelatedField with pk). # This avoids loading full Dataset objects into memory for list responses. + # NB: Calls base manager instead of DatasetManager to avoid select related + # on dataset type and data format, which is not needed for this serializer. dataset_prefetch = Prefetch( 'datasets', - Dataset.objects.only('id') + Dataset._base_manager.only('id') ) queryset = queryset.prefetch_related(dataset_prefetch) return queryset diff --git a/alyx/data/tests.py b/alyx/data/tests.py index eecd71b22..f873a017d 100644 --- a/alyx/data/tests.py +++ b/alyx/data/tests.py @@ -98,7 +98,7 @@ def test_defaults_and_relations(self): dataset = Dataset.objects.create(name='mydataset.npy') notice = DataNotice.objects.create(name='notice-a') - self.assertEqual(notice.severity, DataNotice.IMPORTANCE.INSIGNIFICANT) + self.assertEqual(notice.importance, DataNotice.IMPORTANCE.INSIGNIFICANT) self.assertEqual(str(notice), 'notice-a') notice.datasets.add(dataset) diff --git a/alyx/data/tests_rest.py b/alyx/data/tests_rest.py index 28e8421a8..5a8ea63e8 100644 --- a/alyx/data/tests_rest.py +++ b/alyx/data/tests_rest.py @@ -249,13 +249,13 @@ def test_datanotice_list(self): n1 = DataNotice.objects.create( name='notice-1', - severity=DataNotice.IMPORTANCE.MAJOR, + importance=DataNotice.IMPORTANCE.MAJOR, created_by=self.superuser, ) n1.datasets.add(d1) n2 = DataNotice.objects.create( name='notice-2', - severity=DataNotice.IMPORTANCE.MINOR, + importance=DataNotice.IMPORTANCE.MINOR, created_by=self.superuser, ) n2.datasets.add(d2)