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
2 changes: 1 addition & 1 deletion alyx/alyx/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
Expand Down
108 changes: 105 additions & 3 deletions alyx/data/admin.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
from django.db.models import Count, ProtectedError
from django.db.models import Count, Exists, OuterRef, ProtectedError
from django.contrib import admin, messages
from django.utils.html import format_html
from django_admin_listfilter_dropdown.filters import RelatedDropdownFilter, ChoiceDropdownFilter
from django_admin_listfilter_dropdown.filters import (
RelatedDropdownFilter,
ChoiceDropdownFilter,
SimpleDropdownFilter,
)
from rangefilter.filters import DateRangeFilter

from actions.models import Session
from subjects.models import Project
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


Expand Down Expand Up @@ -231,6 +237,101 @@ 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',
'created_datetime',
'json',
)
readonly_fields = ('created_datetime',)

class DatasetTagListFilter(SimpleDropdownFilter):
title = 'dataset tag'
parameter_name = 'dataset_tag'

def lookups(self, request, model_admin):
return Tag.objects.order_by('name').values_list('id', 'name')

def queryset(self, request, queryset):
"""Filter DataNotice queryset by dataset tag.

This filter avoids joining the full Dataset table directly.
"""
value = self.value()
if not value:
return queryset

notice_dataset_through = DataNotice.datasets.through
dataset_tag_through = Dataset.tags.through

matching_datasets = dataset_tag_through.objects.filter(tag_id=value).values('dataset_id')
matching_notice_datasets = notice_dataset_through.objects.filter(
datanotice_id=OuterRef('pk'), dataset_id__in=matching_datasets)

return queryset.annotate(_has_dataset_tag=Exists(matching_notice_datasets)).filter(
_has_dataset_tag=True)

class SessionProjectListFilter(SimpleDropdownFilter):
title = 'project'
parameter_name = 'project'

def lookups(self, request, model_admin):
notice_dataset_through = DataNotice.datasets.through
session_project_through = Session.projects.through

session_ids = Dataset.objects.filter(
id__in=notice_dataset_through.objects.values('dataset_id'),
session_id__isnull=False,
).values('session_id')
project_ids = session_project_through.objects.filter(
session_id__in=session_ids,
).values('project_id')

return Project.objects.filter(id__in=project_ids).order_by('name').values_list('id', 'name')

def queryset(self, request, queryset):
value = self.value()
if not value:
return queryset

notice_dataset_through = DataNotice.datasets.through
session_project_through = Session.projects.through

matching_sessions = session_project_through.objects.filter(project_id=value).values('session_id')
matching_datasets = Dataset.objects.filter(session_id__in=matching_sessions).values('id')
matching_notice_datasets = notice_dataset_through.objects.filter(
datanotice_id=OuterRef('pk'), dataset_id__in=matching_datasets)

return queryset.annotate(_has_project=Exists(matching_notice_datasets)).filter(
_has_project=True)

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',
)
list_filter = (DatasetTagListFilter, SessionProjectListFilter)
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)
Expand All @@ -240,3 +341,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)
36 changes: 36 additions & 0 deletions alyx/data/migrations/0023_datanotice.py
Comment thread
k1o0 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 5.2.12 on 2026-06-22 11:27

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=20, 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': ('-importance', '-created_datetime', 'name'),
},
),
]
60 changes: 60 additions & 0 deletions alyx/data/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import markdown as _markdown
from one.alf.spec import QC

from django.core.validators import RegexValidator
Expand Down Expand Up @@ -498,3 +499,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(models.IntegerChoices):
CRITICAL = 50
MAJOR = 40
MINOR = 30
INSIGNIFICANT = 20

description = models.TextField(blank=True)
importance = models.IntegerField(
Comment thread
k1o0 marked this conversation as resolved.
default=IMPORTANCE.INSIGNIFICANT, choices=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 = ('-importance', '-created_datetime', 'name')

def __str__(self):
return self.name or str(self.id)
54 changes: 52 additions & 2 deletions alyx/data/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -253,3 +254,52 @@ 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.
# 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._base_manager.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
19 changes: 18 additions & 1 deletion alyx/data/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.importance, 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."""

Expand Down
Loading
Loading