• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

cortex-lab / alyx / 29994645268

23 Jul 2026 09:16AM UTC coverage: 86.879% (+0.3%) from 86.595%
29994645268

Pull #1022

github

oliche
Bump version to 3.6.0 and update changelog
Pull Request #1022: Release 3.6.0

8965 of 10319 relevant lines covered (86.88%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.12
alyx/data/models.py
1
import logging
1✔
2
import markdown as _markdown
1✔
3
from one.alf.spec import QC
1✔
4

5
from django.core.validators import RegexValidator
1✔
6
from django.db import models
1✔
7
from django.conf import settings
1✔
8
from django.utils import timezone
1✔
9
from django.contrib.contenttypes.fields import GenericForeignKey
1✔
10
from django.contrib.contenttypes.models import ContentType
1✔
11

12
from actions.models import Session
1✔
13
from alyx.base import BaseModel, modify_fields, BaseManager, CharNullField, BaseQuerySet, ALF_SPEC
1✔
14

15
logger = logging.getLogger(__name__)
1✔
16

17

18
def _related_string(field):
1✔
19
    return "%(app_label)s_%(class)s_" + field + "_related"
1✔
20

21

22
def default_timezone():
1✔
23
    return settings.TIME_ZONE
1✔
24

25

26
# Data repositories
27
# ------------------------------------------------------------------------------------------------
28

29
class NameManager(models.Manager):
1✔
30
    def get_by_natural_key(self, name):
1✔
31
        return self.get(name=name)
×
32

33

34
class DataRepositoryType(BaseModel):
1✔
35
    """
36
    A type of data repository, e.g. local SAMBA file server; web archive; LTO tape
37
    """
38
    objects = NameManager()
1✔
39

40
    name = models.CharField(max_length=255, unique=True)
1✔
41

42
    class Meta:
1✔
43
        ordering = ('name',)
1✔
44

45
    def __str__(self):
1✔
46
        return "<DataRepositoryType '%s'>" % self.name
×
47

48

49
class DataRepository(BaseModel):
1✔
50
    """
51
    A data repository e.g. a particular local drive, specific cloud storage
52
    location, or a specific tape.
53

54
    Stores an absolute path to the repository root as a URI (e.g. for SMB
55
    file://myserver.mylab.net/Data/ALF/; for web
56
    https://www.neurocloud.edu/Data/). Additional information about the
57
    repository can stored in JSON  in a type-specific manner (e.g. which
58
    cardboard box to find a tape in)
59
    """
60
    objects = NameManager()
1✔
61

62
    name = models.CharField(max_length=255, unique=True)
1✔
63
    repository_type = models.ForeignKey(
1✔
64
        DataRepositoryType, null=True, blank=True, on_delete=models.CASCADE)
65
    hostname = models.CharField(
1✔
66
        max_length=200, blank=True,
67
        validators=[RegexValidator(r'^[a-zA-Z0-9\.\-\_]+$',
68
                                   message='Invalid hostname',
69
                                   code='invalid_hostname')],
70
        help_text="Host name of the network drive")
71
    data_url = models.URLField(
1✔
72
        blank=True, null=True,
73
        help_text="URL of the data repository, if it is accessible via HTTP")
74
    timezone = models.CharField(
1✔
75
        max_length=64, blank=True, default=default_timezone,
76
        help_text="Timezone of the server "
77
        "(see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)")
78
    globus_path = models.CharField(
1✔
79
        max_length=1000, blank=True,
80
        help_text="absolute path to the repository on the server e.g. /mnt/something/")
81
    globus_endpoint_id = models.UUIDField(
1✔
82
        blank=True, null=True, help_text="UUID of the globus endpoint")
83
    globus_is_personal = models.BooleanField(
1✔
84
        null=True, blank=True, help_text="whether the Globus endpoint is personal or not. "
85
        "By default, Globus cannot transfer a file between two personal endpoints.")
86

87
    def __str__(self):
1✔
88
        return "<DataRepository '%s'>" % self.name
1✔
89

90
    class Meta:
1✔
91
        verbose_name_plural = "data repositories"
1✔
92
        ordering = ('name',)
1✔
93

94

95
# Datasets
96
# ------------------------------------------------------------------------------------------------
97

98
class DataFormat(BaseModel):
1✔
99
    """
100
    A descriptor to accompany a Dataset or DataCollection, saying what sort of information is
101
    contained in it. E.g. "Neuropixels raw data, formatted as flat binary file" "eye camera
102
    movie as mj2", etc. Normally each DatasetType will correspond to a specific 3-part alf name
103
    (for individual files) or the first word of the alf names (for DataCollections)
104
    """
105

106
    objects = NameManager()
1✔
107

108
    name = models.CharField(
1✔
109
        max_length=255, unique=True,
110
        help_text="short identifying name, e.g. 'npy'")
111

112
    description = models.CharField(
1✔
113
        max_length=255, blank=True,
114
        help_text="Human-readable description of the file format e.g. 'npy-formatted square "
115
        "numerical array'.")
116

117
    file_extension = models.CharField(
1✔
118
        max_length=255,
119
        validators=[RegexValidator(r'^\.[^\.]+$',
120
                                   message='Invalid file extension, should start with a dot',
121
                                   code='invalid_file_extension')],
122
        help_text="file extension, starting with a dot.")
123

124
    matlab_loader_function = models.CharField(
1✔
125
        max_length=255, blank=True,
126
        help_text="Name of MATLAB loader function'.")
127

128
    python_loader_function = models.CharField(
1✔
129
        max_length=255, blank=True,
130
        help_text="Name of Python loader function'.")
131

132
    class Meta:
1✔
133
        verbose_name_plural = "data formats"
1✔
134
        ordering = ('name',)
1✔
135

136
    def __str__(self):
1✔
137
        return "<DataFormat '%s'>" % self.name
1✔
138

139

140
class DatasetType(BaseModel):
1✔
141
    """
142
    A descriptor to accompany a Dataset or DataCollection, saying what sort of information is
143
    contained in it. E.g. "Neuropixels raw data, formatted as flat binary file" "eye camera
144
    movie as mj2", etc. Normally each DatasetType will correspond to a specific 3-part alf name
145
    (for individual files) or the first word of the alf names (for DataCollections)
146
    """
147

148
    objects = NameManager()
1✔
149

150
    name = models.CharField(
1✔
151
        max_length=255, unique=True, blank=True, null=False,
152
        help_text="Short identifying nickname, e.g. 'spikes.times'")
153

154
    created_by = models.ForeignKey(
1✔
155
        settings.AUTH_USER_MODEL, blank=True, null=True,
156
        on_delete=models.CASCADE,
157
        related_name=_related_string('created_by'),
158
        help_text="The creator of the data.")
159

160
    description = models.CharField(
1✔
161
        max_length=1023, blank=True,
162
        help_text="Human-readable description of data type. Should say what is in the file, and "
163
        "how to read it. For DataCollections, it should list what Datasets are expected in the "
164
        "the collection. E.g. 'Files related to spike events, including spikes.times.npy, "
165
        "spikes.clusters.npy, spikes.amps.npy, spikes.depths.npy")
166

167
    filename_pattern = CharNullField(
1✔
168
        max_length=255, unique=True, null=True, blank=True,
169
        help_text="File name pattern (with wildcards) for this file in ALF naming convention. "
170
        "E.g. 'spikes.times.*' or '*.timestamps.*', or 'spikes.*.*' for a DataCollection, which "
171
        "would include all files starting with the word 'spikes'. NB: Case-insensitive matching."
172
        "If null, the name field must match the object.attribute part of the filename."
173
    )
174

175
    class Meta:
1✔
176
        ordering = ('name',)
1✔
177

178
    def __str__(self):
1✔
179
        return "<DatasetType %s>" % self.name
1✔
180

181
    def save(self, *args, **kwargs):
1✔
182
        """Ensure filename_pattern is lower case."""
183
        if self.filename_pattern:
1✔
184
            self.filename_pattern = self.filename_pattern.lower()
1✔
185
        return super().save(*args, **kwargs)
1✔
186

187

188
class BaseExperimentalData(BaseModel):
1✔
189
    """
190
    Abstract base class for all data acquisition models. Never used directly.
191

192
    Contains an Session link, to provide information about who did the experiment etc. Note that
193
    sessions can be organized hierarchically, and this can point to any level of the hierarchy
194
    """
195
    session = models.ForeignKey(
1✔
196
        Session, blank=True, null=True,
197
        on_delete=models.CASCADE,
198
        related_name=_related_string('session'),
199
        help_text="The Session to which this data belongs")
200

201
    created_by = models.ForeignKey(
1✔
202
        settings.AUTH_USER_MODEL, blank=True, null=True,
203
        on_delete=models.CASCADE,
204
        related_name=_related_string('created_by'),
205
        help_text="The creator of the data.")
206

207
    created_datetime = models.DateTimeField(
1✔
208
        blank=True, null=True, default=timezone.now,
209
        help_text="The creation datetime.")
210

211
    generating_software = models.CharField(
1✔
212
        max_length=255, blank=True,
213
        help_text="e.g. 'ChoiceWorld 0.8.3'")
214

215
    provenance_directory = models.ForeignKey(
1✔
216
        'data.Dataset', blank=True, null=True,
217
        on_delete=models.CASCADE,
218
        related_name=_related_string('provenance'),
219
        help_text="link to directory containing intermediate results")
220

221
    class Meta:
1✔
222
        abstract = True
1✔
223

224

225
def default_dataset_type():
1✔
226
    return DatasetType.objects.get_or_create(name='unknown')[0].pk
1✔
227

228

229
def default_data_format():
1✔
230
    return DataFormat.objects.get_or_create(name='unknown')[0].pk
1✔
231

232

233
class Tag(BaseModel):
1✔
234
    objects = NameManager()
1✔
235
    name = models.CharField(max_length=255, blank=True, help_text="Long name", unique=True)
1✔
236
    description = models.CharField(max_length=1023, blank=True)
1✔
237
    protected = models.BooleanField(default=False)
1✔
238
    public = models.BooleanField(default=False)
1✔
239
    hash = models.CharField(blank=True, null=True, max_length=64,
1✔
240
                            help_text=("Hash of the data buffer, SHA-1 is 40 hex chars, while md5"
241
                                       "is 32 hex chars"))
242

243
    class Meta:
1✔
244
        ordering = ('name',)
1✔
245

246
    def __str__(self):
1✔
247
        return "<Tag %s>" % self.name
×
248

249

250
class Revision(BaseModel):
1✔
251
    """
252
    Dataset revision information
253
    """
254
    objects = NameManager()
1✔
255
    name_validator = RegexValidator(f"^{ALF_SPEC['revision']}$",
1✔
256
                                    "Revisions must only contain letters, "
257
                                    "numbers, hyphens, underscores and forward slashes.")
258
    name = models.CharField(max_length=255, blank=True, help_text="Long name",
1✔
259
                            unique=True, null=False, validators=[name_validator])
260
    description = models.CharField(max_length=1023, blank=True)
1✔
261
    created_datetime = models.DateTimeField(blank=True, null=True, default=timezone.now,
1✔
262
                                            help_text="created date")
263

264
    class Meta:
1✔
265
        ordering = ('name',)
1✔
266

267
    def __str__(self):
1✔
268
        return "<Revision %s>" % self.name
×
269

270
    def save(self, *args, **kwargs):
1✔
271
        self.clean_fields()
1✔
272
        return super(Revision, self).save(*args, **kwargs)
1✔
273

274

275
class DatasetQuerySet(BaseQuerySet):
1✔
276
    """A Queryset that checks for protected datasets before deletion"""
277

278
    def delete(self, force=False):
1✔
279
        if (protected := self.filter(tags__protected=True)).exists():
1✔
280
            if force:
1✔
281
                logger.warning('The following protected datasets will be deleted:\n%s',
1✔
282
                               '\n'.join(map(str, protected.values_list('name', 'session_id'))))
283
            else:
284
                logger.error(
1✔
285
                    'The following protected datasets cannot be deleted without force=True:\n%s',
286
                    '\n'.join(map(str, protected.values_list('name', 'session_id'))))
287
                raise models.ProtectedError(
1✔
288
                    f'Failed to delete {protected.count()} dataset(s) due to protected tags',
289
                    protected)
290
        super().delete()
1✔
291

292

293
class DatasetManager(BaseManager):
1✔
294
    def get_queryset(self):
1✔
295
        qs = DatasetQuerySet(self.model, using=self._db)
1✔
296
        qs = qs.select_related('dataset_type', 'data_format')
1✔
297
        return qs
1✔
298

299

300
@modify_fields(name={
1✔
301
    'blank': False,
302
})
303
class Dataset(BaseExperimentalData):
1✔
304
    """
305
    A chunk of data that is stored outside the database, most often a rectangular binary array.
306
    There can be multiple FileRecords for one Dataset, which will be different physical files,
307
    all containing identical data, with the same MD5.
308

309
    Note that by convention, binary arrays are stored as .npy and text arrays as .tsv
310
    """
311
    objects = DatasetManager()
1✔
312

313
    # Generic foreign key to arbitrary model instances allows polymorphic relationships
314
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
1✔
315
    object_id = models.UUIDField(help_text="UUID of an object whose type matches content_type.",
1✔
316
                                 null=True, blank=True)
317
    content_object = GenericForeignKey()
1✔
318

319
    file_size = models.BigIntegerField(blank=True, null=True, help_text="Size in bytes")
1✔
320

321
    md5 = models.UUIDField(blank=True, null=True,
1✔
322
                           help_text="MD5 hash of the data buffer")
323

324
    hash = models.CharField(blank=True, null=False, max_length=64,
1✔
325
                            help_text=("Hash of the data buffer, SHA-1 is 40 hex chars, while md5"
326
                                       "is 32 hex chars"))
327

328
    # here we usually refer to version as an algorithm version such as ibllib-1.4.2
329
    version = models.CharField(blank=True, null=False, max_length=64,
1✔
330
                               help_text="version of the algorithm generating the file")
331

332
    # the collection comprises session sub-folders
333
    collection_validator = RegexValidator(f"^{ALF_SPEC['collection']}$",
1✔
334
                                          "Collections must only contain letters, "
335
                                          "numbers, hyphens, underscores and forward slashes.")
336
    collection = models.CharField(blank=True, null=False, max_length=255,
1✔
337
                                  help_text='file subcollection or subfolder',
338
                                  validators=[collection_validator])
339

340
    dataset_type = models.ForeignKey(
1✔
341
        DatasetType, blank=False, null=False, on_delete=models.SET_DEFAULT,
342
        default=default_dataset_type)
343

344
    data_format = models.ForeignKey(
1✔
345
        DataFormat, blank=False, null=False, on_delete=models.SET_DEFAULT,
346
        default=default_data_format)
347

348
    revision = models.ForeignKey(
1✔
349
        Revision, blank=True, null=True, on_delete=models.SET_NULL)
350

351
    tags = models.ManyToManyField('data.Tag', blank=True, related_name='datasets')
1✔
352

353
    auto_datetime = models.DateTimeField(auto_now=True, blank=True, null=True,
1✔
354
                                         verbose_name='last updated')
355

356
    default_dataset = models.BooleanField(default=True,
1✔
357
                                          help_text="Whether this dataset is the default "
358
                                                    "latest revision")
359

360
    QC_CHOICES = [(e.value, e.name) for e in QC]
1✔
361
    qc = models.IntegerField(default=QC.NOT_SET, choices=QC_CHOICES,
1✔
362
                             help_text=' / '.join([str(q[0]) + ': ' + q[1] for q in QC_CHOICES]))
363

364
    @property
1✔
365
    def is_online(self):
1✔
366
        fr = self.file_records.filter(data_repository__globus_is_personal=False)
1✔
367
        return bool(fr.count() and any(fr.values_list('exists', flat=True)))
1✔
368

369
    @property
1✔
370
    def is_protected(self):
1✔
371
        return bool(self.tags.filter(protected=True).count())
1✔
372

373
    @property
1✔
374
    def is_public(self):
1✔
375
        return bool(self.tags.filter(public=True).count())
1✔
376

377
    @property
1✔
378
    def data_url(self):
1✔
379
        records = self.file_records.filter(data_repository__data_url__isnull=False, exists=True)
1✔
380
        # returns preferentially globus non-personal endpoint
381
        if records:
1✔
382
            order_keys = ('data_repository__globus_is_personal', '-data_repository__name')
×
383
            return records.order_by(*order_keys)[0].data_url
×
384

385
    def __str__(self):
1✔
386
        date = self.created_datetime.strftime('%d/%m/%Y at %H:%M')
1✔
387
        return "<Dataset %s %s '%s' by %s on %s>" % (
1✔
388
            str(self.pk)[:8], getattr(self.dataset_type, 'name', ''),
389
            self.name, self.created_by, date)
390

391
    def save(self, *args, **kwargs):
1✔
392
        # when a dataset is saved / created make sure the probe insertion is set in the reverse m2m
393
        super(Dataset, self).save(*args, **kwargs)
1✔
394
        if not self.collection:
1✔
395
            return
1✔
396
        self.clean_fields()  # Validate collection field
1✔
397
        from experiments.models import ProbeInsertion, FOV
1✔
398
        parts = self.collection.rsplit('/')
1✔
399
        if len(parts) > 1:
1✔
400
            name = parts[1]
1✔
401
            pis = ProbeInsertion.objects.filter(session=self.session, name=name)
1✔
402
            if len(pis):
1✔
403
                self.probe_insertion.set(pis.values_list('pk', flat=True))
1✔
404
            fovs = FOV.objects.filter(session=self.session, name=name)
1✔
405
            if len(fovs):
1✔
406
                self.field_of_view.set(fovs.values_list('pk', flat=True))
×
407

408
    def delete(self, *args, force=False, **kwargs):
1✔
409
        # If a dataset is protected and force=False, raise an exception
410
        # NB This is not called when bulk deleting or in cascading deletes
411
        if self.is_protected and not force:
1✔
412
            tags = self.tags.filter(protected=True).values_list('name', flat=True)
1✔
413
            tags_str = '"' + '", "'.join(tags) + '"'
1✔
414
            logger.error(f'Dataset {self.name} is protected by tag(s); use force=True.')
1✔
415
            raise models.ProtectedError(
1✔
416
                f'Failed to delete dataset {self.name} due to protected tag(s) {tags_str}', self)
417
        super().delete(*args, **kwargs)
×
418

419

420
# Files
421
# ------------------------------------------------------------------------------------------------
422
class FileRecordManager(models.Manager):
1✔
423
    def get_queryset(self):
1✔
424
        qs = super(FileRecordManager, self).get_queryset()
1✔
425
        qs = qs.select_related('data_repository')
1✔
426
        return qs
1✔
427

428

429
class FileRecord(BaseModel):
1✔
430
    """
431
    A single file on disk or tape. Normally specified by a path within an archive. If required,
432
    more details can be in the JSON
433
    """
434

435
    objects = FileRecordManager()
1✔
436

437
    dataset = models.ForeignKey(Dataset, related_name='file_records', on_delete=models.CASCADE)
1✔
438

439
    data_repository = models.ForeignKey(
1✔
440
        'DataRepository', on_delete=models.CASCADE)
441

442
    relative_path = models.CharField(
1✔
443
        max_length=1000,
444
        validators=[RegexValidator(r'^[a-zA-Z0-9\_][^\\\:]+$',
445
                                   message='Invalid path',
446
                                   code='invalid_path')],
447
        help_text="path name within repository")
448

449
    exists = models.BooleanField(
1✔
450
        default=False, help_text="Whether the file exists in the data repository", )
451

452
    class Meta:
1✔
453
        unique_together = (('data_repository', 'relative_path'),)
1✔
454

455
    @property
1✔
456
    def data_url(self):
1✔
457
        root = self.data_repository.data_url
1✔
458
        if not root:
1✔
459
            return None
1✔
460
        from one.alf.path import add_uuid_string
×
461
        return root + add_uuid_string(self.relative_path, self.dataset.pk).as_posix()
×
462

463
    def save(self, *args, **kwargs):
1✔
464
        """this is to trigger the update of the auto-date field"""
465
        super(FileRecord, self).save(*args, **kwargs)
1✔
466
        # Save the dataset as well to make sure the auto datetime in the dateset is updated when
467
        # associated file record is saved
468
        self.dataset.save()
1✔
469

470
    def __str__(self):
1✔
471
        return "<FileRecord '%s' by %s>" % (self.relative_path, self.dataset.created_by)
×
472

473

474
# Download table
475
# ------------------------------------------------------------------------------------------------
476

477
class Download(BaseModel):
1✔
478
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
1✔
479
    dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE)
1✔
480
    first_download = models.DateTimeField(auto_now_add=True)
1✔
481
    last_download = models.DateTimeField(auto_now=True)
1✔
482
    count = models.IntegerField(default=0)
1✔
483
    projects = models.ManyToManyField('subjects.Project', blank=True)
1✔
484

485
    class Meta:
1✔
486
        unique_together = (('user', 'dataset'),)
1✔
487

488
    def increment(self):
1✔
489
        self.count += 1
1✔
490
        self.save()
1✔
491

492
    def __str__(self):
1✔
493
        return '<Download of %s dataset by %s (%d)>' % (
×
494
            self.dataset.dataset_type.name, self.user.username, self.count)
495

496

497
def new_download(dataset, user, projects=()):
1✔
498
    d, _ = Download.objects.get_or_create(user=user, dataset=dataset)
1✔
499
    d.projects.add(*projects)
1✔
500
    d.increment()
1✔
501
    return d
1✔
502

503

504
class DataNotice(BaseModel):
1✔
505
    """A notice about data quality issues that may affect one or more datasets."""
506

507
    class IMPORTANCE(models.IntegerChoices):
1✔
508
        CRITICAL = 50
1✔
509
        MAJOR = 40
1✔
510
        MINOR = 30
1✔
511
        INSIGNIFICANT = 20
1✔
512

513
    description = models.TextField(blank=True)
1✔
514
    importance = models.IntegerField(
1✔
515
        default=IMPORTANCE.INSIGNIFICANT, choices=IMPORTANCE,
516
        help_text=' / '.join([f'{q.value}: {q.name}' for q in IMPORTANCE]))
517

518
    datasets = models.ManyToManyField(
1✔
519
        Dataset, blank=True, related_name='data_notices')
520
    created_by = models.ForeignKey(
1✔
521
        settings.AUTH_USER_MODEL,
522
        null=True,
523
        blank=True,
524
        on_delete=models.SET_NULL,
525
        related_name='data_notices',
526
    )
527
    created_datetime = models.DateTimeField(auto_now_add=True)
1✔
528
    version_affected = models.CharField(max_length=64, blank=True)
1✔
529
    affected_date_start = models.DateField(null=True, blank=True)
1✔
530
    affected_date_end = models.DateField(null=True, blank=True)
1✔
531

532
    def description_html(self):
1✔
533
        """Render description as safe HTML via markdown."""
534
        if not self.description:
×
535
            return ''
×
536
        return _markdown.markdown(self.description, extensions=['extra'])
×
537

538
    def importance_panel_class(self):
1✔
539
        """Bootstrap panel class for this notice's importance level."""
540
        return {
×
541
            self.IMPORTANCE.CRITICAL: 'danger',
542
            self.IMPORTANCE.MAJOR: 'warning',
543
            self.IMPORTANCE.MINOR: 'info',
544
            self.IMPORTANCE.INSIGNIFICANT: 'default',
545
        }.get(self.importance, 'default')
546

547
    def importance_badge_color(self):
1✔
548
        """Hex color for the importance badge."""
549
        return {
×
550
            self.IMPORTANCE.CRITICAL: '#c9302c',
551
            self.IMPORTANCE.MAJOR: '#ec971f',
552
            self.IMPORTANCE.MINOR: '#31b0d5',
553
            self.IMPORTANCE.INSIGNIFICANT: '#6c757d',
554
        }.get(self.importance, '#6c757d')
555

556
    class Meta:
1✔
557
        ordering = ('-importance', '-created_datetime', 'name')
1✔
558

559
    def __str__(self):
1✔
560
        return self.name or str(self.id)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc