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
36 changes: 35 additions & 1 deletion alyx/data/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand All @@ -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)
37 changes: 37 additions & 0 deletions alyx/data/migrations/0023_datanotice.py
Original file line number Diff line number Diff line change
@@ -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'),
},
),
]
61 changes: 61 additions & 0 deletions alyx/data/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
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