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

cortex-lab / alyx / 19134521230

06 Nov 2025 11:42AM UTC coverage: 85.574% (+0.2%) from 85.337%
19134521230

push

github

web-flow
Timezone parameter in docker (#937)

* Timezone parameter in docker

- This allows users to change the timezone without requiring model migrations.
- This commit itself will require a model migration
- Containers by default would be in UTC
- Users can override Alyx timezone although logs etc. will remain UTC

* Initial form value

* Add migrations

* Admin form tests

* Correct settings import

8340 of 9746 relevant lines covered (85.57%)

0.86 hits per line

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

83.0
alyx/misc/models.py
1
from datetime import datetime
1✔
2
from io import BytesIO
1✔
3
import os.path as op
1✔
4
import uuid
1✔
5
import sys
1✔
6
import pytz
1✔
7

8
from PIL import Image
1✔
9

10
from django.db.models import Q
1✔
11
from django.contrib.auth import get_user_model
1✔
12
from django.db import models
1✔
13
from django.conf import settings
1✔
14
from django.core import validators
1✔
15
from django.contrib.auth.models import AbstractUser
1✔
16
from django.contrib.contenttypes.fields import GenericForeignKey
1✔
17
from django.contrib.contenttypes.models import ContentType
1✔
18
from django.core.files.uploadedfile import InMemoryUploadedFile
1✔
19
from django.utils import timezone
1✔
20

21
from alyx.base import BaseModel, modify_fields, ALF_SPEC
1✔
22

23

24
def default_lab():
1✔
25
    return settings.DEFAULT_LAB_PK
1✔
26

27

28
class LabMember(AbstractUser):
1✔
29
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
1✔
30
    is_stock_manager = models.BooleanField(default=False)
1✔
31
    allowed_users = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
1✔
32
    is_public_user = models.BooleanField(default=False)
1✔
33

34
    class Meta:
1✔
35
        ordering = ['username']
1✔
36

37
    def lab_id(self, date=datetime.now().date()):
1✔
38
        lms = LabMembership.objects.filter(user=self.pk, start_date__lte=date)
1✔
39
        lms = lms.exclude(end_date__lt=date)
1✔
40
        return Lab.objects.filter(id__in=lms.values_list('lab', flat=True))
1✔
41

42
    @property
1✔
43
    def lab(self, date=datetime.now().date()):
1✔
44
        labs = self.lab_id(date=date)
1✔
45
        return [str(ln[0]) for ln in labs.values_list('name').distinct()]
1✔
46

47
    @property
1✔
48
    def tz(self):
1✔
49
        labs = self.lab_id()
×
50
        if not labs:
×
51
            return settings.TIME_ZONE
×
52
        else:
53
            return labs[0].timezone
×
54

55
    def get_allowed_subjects(self, subjects_queryset=None):
1✔
56
        """
57
        Find all subjects for which the user is responsible and has delegated access
58
        The stock managers and super users have access to all subjects
59
        :param subjects_queryset: Queryset of subjects to filter on, if None, all subjects
60
        :return: Queryset of subjects accessible by the user
61
        """
62
        if subjects_queryset is None:
1✔
63
            from subjects.models import Subject  # avoid circular import
1✔
64
            subjects_queryset = Subject.objects.all()
1✔
65
        # stock mangers or super users have access to all subjects
66
        if self.is_superuser or self.is_stock_manager:
1✔
67
            return subjects_queryset
1✔
68
        responsible_users = get_user_model().objects.filter(Q(allowed_users=self) | Q(pk=self.pk))
1✔
69
        return subjects_queryset.filter(responsible_user__in=responsible_users)
1✔
70

71

72
class Lab(BaseModel):
1✔
73
    labname_validator = validators.RegexValidator(
1✔
74
        f"^{ALF_SPEC['lab']}$",
75
        "Lab name must only contain letters, numbers, and underscores.")
76
    name = models.CharField(max_length=255, unique=True, validators=[labname_validator])
1✔
77
    institution = models.CharField(max_length=255, blank=True)
1✔
78
    address = models.CharField(max_length=255, blank=True)
1✔
79
    timezone = models.CharField(
1✔
80
        max_length=64, blank=True,
81
        help_text="Timezone of the server "
82
        "(see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)")
83

84
    reference_weight_pct = models.FloatField(
1✔
85
        default=0.,
86
        help_text="The minimum mouse weight is a linear combination of "
87
        "the reference weight and the zscore weight.")
88

89
    zscore_weight_pct = models.FloatField(
1✔
90
        default=0.,
91
        help_text="The minimum mouse weight is a linear combination of "
92
        "the reference weight and the zscore weight.")
93
    # those are all the default fields for populating Housing tables
94
    cage_type = models.ForeignKey('CageType', on_delete=models.SET_NULL, null=True, blank=True)
1✔
95
    enrichment = models.ForeignKey('Enrichment', on_delete=models.SET_NULL, null=True, blank=True)
1✔
96
    food = models.ForeignKey('Food', on_delete=models.SET_NULL, null=True, blank=True)
1✔
97
    cage_cleaning_frequency_days = models.IntegerField(null=True, blank=True)
1✔
98
    light_cycle = models.IntegerField(choices=((0, 'Normal'),
1✔
99
                                               (1, 'Inverted'),), null=True, blank=True)
100
    repositories = models.ManyToManyField(
1✔
101
        'data.DataRepository', blank=True,
102
        help_text="Related DataRepository instances. Any file which is registered to Alyx is "
103
        "automatically copied to all repositories assigned to its project.")
104

105
    def __str__(self):
1✔
106
        return self.name
1✔
107

108
    def save(self, *args, timezone=None, **kwargs):
1✔
109
        self.timezone = timezone or self.timezone or settings.TIME_ZONE
1✔
110
        super().save(*args, **kwargs)
1✔
111

112

113
class LabMembership(BaseModel):
1✔
114
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
1✔
115
    lab = models.ForeignKey(Lab, on_delete=models.CASCADE)
1✔
116
    role = models.CharField(max_length=255, blank=True)
1✔
117
    start_date = models.DateField(blank=True, null=True, default=timezone.now)
1✔
118
    end_date = models.DateField(blank=True, null=True)
1✔
119

120
    def __str__(self):
1✔
121
        return "%s %s in %s" % (self.user, self.role, self.lab)
×
122

123

124
@modify_fields(name={
1✔
125
    'blank': False,
126
})
127
class LabLocation(BaseModel):
1✔
128
    """
129
    The physical location at which an session is performed or appliances are located.
130
    This could be a room, a bench, a rig, etc.
131
    """
132
    lab = models.ForeignKey(Lab, on_delete=models.CASCADE, default=default_lab)
1✔
133

134
    def __str__(self):
1✔
135
        return self.name
1✔
136

137

138
def get_image_path(instance, filename):
1✔
139
    date = datetime.now().strftime('%Y/%m/%d')
×
140
    pk = instance.object_id
×
141
    base, ext = op.splitext(filename)
×
142
    return '%s/%s.%s%s' % (date, base, pk, ext)
×
143

144

145
class Note(BaseModel):
1✔
146
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
1✔
147
    date_time = models.DateTimeField(default=timezone.now)
1✔
148
    text = models.TextField(blank=True,
1✔
149
                            help_text="String, content of the note or description of the image.")
150
    image = models.ImageField(upload_to=get_image_path, blank=True, null=True)
1✔
151

152
    # Generic foreign key to arbitrary model instances.
153
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
1✔
154
    object_id = models.UUIDField(help_text="UUID, an object of content_type with this "
1✔
155
                                           "ID must already exist to attach a note.")
156
    content_object = GenericForeignKey()
1✔
157

158
    def save(self, image_width=None, **kwargs):
1✔
159
        if self.image and not self._state.adding and image_width != 'orig':
1✔
160
            # Resize image - saving
161
            with Image.open(self.image) as im:
×
162
                with BytesIO() as output:
×
163
                    # Compute new size by keeping the aspect ratio.
164
                    width = int(image_width or settings.UPLOADED_IMAGE_WIDTH)
×
165
                    wpercent = width / float(im.size[0])
×
166
                    height = int((float(im.size[1]) * float(wpercent)))
×
167
                    im.thumbnail((width, height))
×
168
                    im.save(output, format=im.format, quality=70)
×
169
                    output.seek(0)
×
170
                    self.image = InMemoryUploadedFile(
×
171
                        output, 'ImageField', self.image.name,
172
                        im.format, sys.getsizeof(output), None)
173
                    super(Note, self).save(**kwargs)
×
174
        else:
175
            super(Note, self).save(**kwargs)
1✔
176

177

178
class CageType(BaseModel):
1✔
179
    description = models.CharField(
1✔
180
        max_length=1023, blank=True, help_text="Extended description of the cage product/brand")
181

182
    def __str__(self):
1✔
183
        return self.name
×
184

185

186
class Enrichment(BaseModel):
1✔
187
    name = models.CharField(max_length=255, unique=True,
1✔
188
                            help_text="Training wheel, treadmill etc..")
189
    description = models.CharField(
1✔
190
        max_length=1023, blank=True, help_text="Extended description of the enrichment, link ...")
191

192
    def __str__(self):
1✔
193
        return self.name
×
194

195

196
class Food(BaseModel):
1✔
197
    name = models.CharField(max_length=255, unique=True,
1✔
198
                            help_text="Food brand and content")
199
    description = models.CharField(
1✔
200
        max_length=1023, blank=True, help_text="Extended description of the food, link ...")
201

202
    def __str__(self):
1✔
203
        return self.name
×
204

205

206
class Housing(BaseModel):
1✔
207
    """
208
    Table containing housing conditions. Subjects are linked through the HousingSubject table
209
    that contains the date_in / date_out for each Housing.
210
    NB: housing is not a physical cage, although it refers to it by cage_name.
211
    For history recording purposes, if an enrichment/food in a physical cage changes, then:
212
    1) creates a new Housing instance
213
    2) closes (set end_datetime) for current mice in junction table
214
    3) creates HousingSubject records for the current mice and new Housing
215
    """
216
    subjects = models.ManyToManyField('subjects.Subject', through='HousingSubject',
1✔
217
                                      related_name='housings')
218
    cage_name = models.CharField(max_length=64)
1✔
219
    cage_type = models.ForeignKey('CageType', on_delete=models.SET_NULL, null=True, blank=True)
1✔
220
    enrichment = models.ForeignKey('Enrichment', on_delete=models.SET_NULL, null=True, blank=True)
1✔
221
    food = models.ForeignKey('Food', on_delete=models.SET_NULL, null=True, blank=True)
1✔
222
    cage_cleaning_frequency_days = models.IntegerField(null=True, blank=True)
1✔
223
    light_cycle = models.IntegerField(choices=((0, 'Normal'),
1✔
224
                                               (1, 'Inverted'),),
225
                                      null=True, blank=True)
226

227
    def __str__(self):
1✔
228
        return self.cage_name + ' (housing: ' + str(self.pk)[:8] + ')'
×
229

230
    def save(self, **kwargs):
1✔
231
        # if this is a forced update/insert, save and continue to avoid recursion
232
        if kwargs.get('force_update', False) or kwargs.get('force_insert', False):
1✔
233
            super(Housing, self).save(**kwargs)
1✔
234
            return
1✔
235
        # first check if it's an update to an existing value, if not, just create
236
        housings = Housing.objects.filter(pk=self.pk)
1✔
237
        if not housings:
1✔
238
            super(Housing, self).save(**kwargs)
×
239
            return
×
240
        # so if it's an update check for field changes excluding end date which is an update
241
        old = Housing.objects.get(pk=self.pk)
1✔
242
        excludes = ['json', 'name']  # do not track changes to those fields
1✔
243
        isequal = True
1✔
244
        for f in self._meta.fields:
1✔
245
            if f.name in excludes:
1✔
246
                continue
1✔
247
            isequal &= getattr(old, f.name) == getattr(self, f.name)
1✔
248
        # in this case the housing may just have had comments or json changed
249
        if isequal:
1✔
250
            super(Housing, self).save(**kwargs)
×
251
            return
×
252
        # update fields triggers 1) the update of the current rec, 2) the creation of a new rec
253
        self.close_and_create()
1✔
254

255
    def close_and_create(self):
1✔
256
        # get the old/current object
257
        old = Housing.objects.get(pk=self.pk)
1✔
258
        subs = old.subjects.all()
1✔
259
        if not subs:
1✔
260
            return
×
261
        self.pk = None
1✔
262
        self.save(force_insert=True)
1✔
263
        subs = old.subjects_current()
1✔
264
        if not subs:
1✔
265
            return
1✔
266
        # 1) update of the old model(s), setting the end time
267
        now = datetime.now(tz=timezone.get_current_timezone())
1✔
268
        if subs.first().lab:
1✔
269
            now = now.astimezone(pytz.timezone(subs.first().lab.timezone))
1✔
270
        old.housing_subjects.all().update(end_datetime=now)
1✔
271
        # 2) update of the current model and create start time
272
        for sub in subs:
1✔
273
            HousingSubject.objects.create(subject=sub, housing=self, start_datetime=now)
1✔
274

275
    def subjects_current(self, datetime=None):
1✔
276
        from subjects.models import Subject
1✔
277
        if datetime:
1✔
278
            hs = self.housing_subjects.filter(end_datetime__gte=datetime)
1✔
279
            hs = hs.filter(start_datetime__lte=datetime)
1✔
280
            pass
1✔
281
        else:
282
            hs = self.housing_subjects.filter(end_datetime__isnull=True)
1✔
283
        return Subject.objects.filter(pk__in=hs.values_list('subject', flat=True))
1✔
284

285
    @property
1✔
286
    def subject_count(self):
1✔
287
        return self.subjects.objects.all().count()
×
288

289
    @property
1✔
290
    def lab(self):
1✔
291
        sub = self.subjects.first()
×
292
        if sub:
×
293
            return sub.lab.name
×
294

295

296
class HousingSubject(BaseModel):
1✔
297
    """
298
    Through model for Housing and Subjects m2m
299
    """
300
    subject = models.ForeignKey('subjects.Subject',
1✔
301
                                related_name='housing_subjects',
302
                                on_delete=models.SET_NULL,
303
                                null=True)
304
    housing = models.ForeignKey('Housing',
1✔
305
                                related_name='housing_subjects',
306
                                on_delete=models.SET_NULL,
307
                                null=True,
308
                                blank=True)
309
    start_datetime = models.DateTimeField()
1✔
310
    end_datetime = models.DateTimeField(null=True, blank=True)
1✔
311

312
    def save(self, **kwargs):
1✔
313
        # if this is a forced update, save and continue to avoid recursion
314
        if kwargs.get('force_update', False):
1✔
315
            super(HousingSubject, self).save(**kwargs)
×
316
            return
×
317
        old = HousingSubject.objects.filter(pk=self.pk)
1✔
318
        # if the subject is in another housing, close the other
319
        if not self.end_datetime:
1✔
320
            hs = HousingSubject.objects.filter(subject=self.subject, end_datetime__isnull=True
1✔
321
                                               ).exclude(pk__in=old.values_list('pk', flat=True))
322
            hs.update(end_datetime=self.start_datetime)
1✔
323
        # if this is a modification of an existing object, force update the old and force insert
324
        if old:
1✔
325
            old[0].end_datetime = self.start_datetime
1✔
326
            super(HousingSubject, self).save(force_update=True)  # self.save(force_insert=True)
1✔
327
            return
1✔
328
        super(HousingSubject, self).save(**kwargs)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc