diff --git a/alyx/actions/admin.py b/alyx/actions/admin.py index f764ab5cc..fbbf95adf 100644 --- a/alyx/actions/admin.py +++ b/alyx/actions/admin.py @@ -244,15 +244,6 @@ def session_l(self, obj): class WaterRestrictionForm(forms.ModelForm): - implant_weight = forms.FloatField() - - def save(self, commit=True): - implant_weight = self.cleaned_data.get('implant_weight') - subject = self.cleaned_data.get('subject', None) - if implant_weight: - subject.implant_weight = implant_weight - subject.save() - return super(WaterRestrictionForm, self).save(commit=commit) class Meta: model = WaterRestriction @@ -272,22 +263,20 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): def get_form(self, request, obj=None, **kwargs): form = super(WaterRestrictionAdmin, self).get_form(request, obj, **kwargs) subject = getattr(obj, 'subject', None) - iw = getattr(subject, 'implant_weight', None) rw = subject.water_control.weight() if subject else None - form.base_fields['implant_weight'].initial = iw if self.has_change_permission(request, obj): form.base_fields['reference_weight'].initial = rw or 0 return form form = WaterRestrictionForm - fields = ['subject', 'implant_weight', 'reference_weight', - 'start_time', 'end_time', 'water_type', 'users', 'narrative'] + fields = ['subject', 'reference_weight', 'start_time', + 'end_time', 'water_type', 'users', 'narrative', 'implant_weight'] list_display = ('subject_w', 'start_time_l', 'end_time_l', 'water_type', 'weight', 'weight_ref') + WaterControl._columns[3:] + ('projects',) list_select_related = ('subject',) list_display_links = ('start_time_l', 'end_time_l') - readonly_fields = ('weight',) # WaterControl._columns[1:] + readonly_fields = ('weight', 'implant_weight') # WaterControl._columns[1:] ordering = ['-start_time', 'subject__nickname'] search_fields = ['subject__nickname', 'subject__projects__name'] list_filter = [ResponsibleUserListFilter, @@ -361,6 +350,12 @@ def given_water_total(self, obj): return '%.2f' % obj.subject.water_control.given_water_total() given_water_total.short_description = 'water tot' + def implant_weight(self, obj): + if not obj.subject: + return + return '%.2f' % (obj.subject.water_control.implant_weight() or 0.) + implant_weight.short_description = 'implant weight' + def has_change_permission(self, request, obj=None): # setting to override edition of water restrictions in the settings.lab file override = getattr(settings, 'WATER_RESTRICTIONS_EDITABLE', False) @@ -422,10 +417,11 @@ class WaterTypeAdmin(BaseActionAdmin): class SurgeryAdmin(BaseActionAdmin): - list_display = ['subject_l', 'date', 'users_l', 'procedures_l', 'narrative', 'projects'] + list_display = ['subject_l', 'date', 'users_l', 'procedures_l', + 'narrative', 'projects', 'implant_weight'] list_select_related = ('subject',) - fields = BaseActionAdmin.fields + ['outcome_type'] + fields = BaseActionAdmin.fields + ['outcome_type', 'implant_weight'] list_display_links = ['date'] search_fields = ('subject__nickname', 'subject__projects__name') list_filter = [SubjectAliveListFilter, diff --git a/alyx/actions/migrations/0022_project_to_projects.py b/alyx/actions/migrations/0022_project_to_projects.py index b0a400110..ae408e625 100644 --- a/alyx/actions/migrations/0022_project_to_projects.py +++ b/alyx/actions/migrations/0022_project_to_projects.py @@ -6,6 +6,7 @@ logger = logging.getLogger(__name__) + def project2projects(apps, schema_editor): """ Find sessions where the project field (singular) value is not in the projects (plural) many-to-many diff --git a/alyx/actions/migrations/0024_surgery_implant_weight.py b/alyx/actions/migrations/0024_surgery_implant_weight.py new file mode 100644 index 000000000..86f60efb7 --- /dev/null +++ b/alyx/actions/migrations/0024_surgery_implant_weight.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.10 on 2024-03-15 13:53 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('actions', '0023_remove_session_project'), + ] + + operations = [ + migrations.AddField( + model_name='surgery', + name='implant_weight', + field=models.FloatField(blank=True, default=0, help_text='Implant weight in grams', validators=[django.core.validators.MinValueValidator(0)]), + preserve_default=False, + ), + migrations.AddConstraint( + model_name='surgery', + constraint=models.CheckConstraint(check=models.Q(('implant_weight__gte', 0)), name='implant_weight_gte_0'), + ), + ] diff --git a/alyx/actions/migrations/0025_move_implant_weight.py b/alyx/actions/migrations/0025_move_implant_weight.py new file mode 100644 index 000000000..dda4187d2 --- /dev/null +++ b/alyx/actions/migrations/0025_move_implant_weight.py @@ -0,0 +1,71 @@ +# Generated by Django 4.2.10 on 2024-03-15 13:53 +""" +In 2024-03 the Subject.implant_weight field was removed and the Surgery.implant_weight field +was added. This script 'saves' the non-zero implant weights to the Subject.JSON field and if +unambiguous, saves the implant weight to the relevent surgery. +""" +import logging +from pathlib import Path +from datetime import datetime, timezone + +from django.db import migrations +from django.core import serializers + +import alyx + +logger = logging.getLogger(__name__) + + +def move_implant_weight(apps, schema_editor): + Subject = apps.get_model('subjects', 'Subject') + ProcedureType = apps.get_model('actions', 'ProcedureType') + try: + headplate_implant = ProcedureType.objects.get(name='Headplate implant') + except ProcedureType.DoesNotExist: + headplate_implant = None + query = Subject.objects.filter(implant_weight__gt=0) + now = datetime.now(timezone.utc).isoformat() + n = 0 + for subject in query: + # Add implant weight to JSON field for prosperity + iw = subject.implant_weight + json = subject.json or {} + d = {'implant_weight': [{'value': iw, 'datetime': now}]} + if 'history' in json: + json['history'].update(d) + else: + json['history'] = d + subject.json = json + subject.save() + # If possible, add implant weight to previous surgery + surgeries = subject.actions_surgerys.filter(procedures__name__icontains='implant') + if surgeries.count() == 0: + # If no surgeries contain an implant procedure, attempt to find one surgery where + # implant or headplate are mentioned in the narrative + surgeries = subject.actions_surgerys.filter(narrative__iregex='.*(headplate|implant).*') + # If there is an unambiguous result, set the surgery implant weight + if surgeries.count() == 1: + surgery = surgeries.first() + surgery.implant_weight = iw + # If headplate is mentioned in the narrative, add Headplate implant procedure + # to the surgeries procedures list + if headplate_implant and 'headplate' in surgery.narrative.lower(): + surgery.procedures.add(headplate_implant) + surgery.save() + n += 1 + + logger.info(f'implant weights: {query.count():,g} subjects; {n:,g} surgeries updated') + if query.count(): + filename = now[:19].replace(':', '-') + '_subject-implant-weight.json' + filepath = Path(alyx.__file__).parents[2].joinpath('data', filename) + with open(filepath, 'w') as fp: + fp.write(serializers.serialize('json', query, fields=['implant_weight'])) + + +class Migration(migrations.Migration): + + dependencies = [ + ('actions', '0024_surgery_implant_weight'), + ] + + operations = [migrations.RunPython(move_implant_weight)] diff --git a/alyx/actions/models.py b/alyx/actions/models.py index c6b6adb96..488cfb549 100644 --- a/alyx/actions/models.py +++ b/alyx/actions/models.py @@ -203,9 +203,15 @@ class Surgery(BaseAction): default=_default_surgery_location, help_text="The physical location at which the surgery was " "performed") + implant_weight = models.FloatField(null=False, blank=True, validators=[MinValueValidator(0)], + help_text="Implant weight in grams") class Meta: verbose_name_plural = "surgeries" + constraints = [ + models.CheckConstraint( + check=models.Q(implant_weight__gte=0), name="implant_weight_gte_0"), + ] def save(self, *args, **kwargs): # Issue #422. @@ -336,6 +342,7 @@ def save(self, *args, **kwargs): self.reference_weight = w[1] # makes sure the closest weighing is one week around, break if not assert abs(w[0] - self.start_time) < timedelta(days=7) + output = super(WaterRestriction, self).save(*args, **kwargs) # When creating a water restriction, the subject's protocol number should be changed to 3 # (request by Charu in 03/2022) diff --git a/alyx/actions/tests.py b/alyx/actions/tests.py index c457874c4..92b5711bd 100644 --- a/alyx/actions/tests.py +++ b/alyx/actions/tests.py @@ -7,7 +7,7 @@ from actions.water_control import to_date from actions.models import ( WaterAdministration, WaterRestriction, WaterType, Weighing, - Notification, NotificationRule, create_notification) + Notification, NotificationRule, create_notification, Surgery, ProcedureType) from actions.notifications import check_water_administration, check_weighed from misc.models import LabMember, LabMembership, Lab from subjects.models import Subject @@ -40,6 +40,18 @@ def setUp(self): Lab.objects.create(name='zscore', reference_weight_pct=0, zscore_weight_pct=0.85) Lab.objects.create(name='rweigh', reference_weight_pct=0.85, zscore_weight_pct=0) Lab.objects.create(name='mixed', reference_weight_pct=0.425, zscore_weight_pct=0.425) + # create some surgeries to go with it (for testing implant weight in calculations) + date = self.start_date - datetime.timedelta(days=7) + surgery0 = Surgery.objects.create(subject=self.sub, implant_weight=4.56, start_time=date) + implant_proc, _ = ProcedureType.objects.get_or_create(name='Headplate implant') + surgery0.procedures.add(implant_proc.pk) + date = self.start_date + datetime.timedelta(days=10) + surgery1 = Surgery.objects.create(subject=self.sub, implant_weight=0., start_time=date) + date = self.start_date + datetime.timedelta(days=25) + surgery2 = Surgery.objects.create(subject=self.sub, implant_weight=7., start_time=date) + surgery2.procedures.add(implant_proc.pk) + self.surgeries = [surgery0, surgery1, surgery2] + # Create an initial Water Restriction start_wr = self.start_date + datetime.timedelta(days=self.rwind) water_type = WaterType.objects.get(name='Hydrogel 5% Citric Acid') @@ -68,29 +80,36 @@ def test_water_control_thresholds(self): self.sub.save() wc = self.sub.reinit_water_control() wc.expected_weight() - self.assertAlmostEqual(self.wei[self.rwind], wc.reference_weight()) - self.assertAlmostEqual(self.wei[self.rwind], wc.expected_weight()) + # expected weight should be different to reference weight as the implant weight changes + expected = self.wei[self.rwind] + (wc.implant_weights[1][1] - wc.implant_weights[0][1]) + self.assertAlmostEqual(expected, wc.reference_weight()) + self.assertAlmostEqual(expected, wc.expected_weight()) + # test implant weight values + self.assertEqual([4.56, 7.0], [x[1] for x in wc.implant_weights]) + self.assertEqual(7.0, wc.implant_weight()) + self.assertEqual(4.56, wc.implant_weight(self.start_date)) + self.assertEqual(4.56, wc.reference_implant_weight_at()) # test computation on zscore weight lab alone self.sub.lab = Lab.objects.get(name='zscore') self.sub.save() wc = self.sub.reinit_water_control() - wc.expected_weight() - self.assertAlmostEqual(self.wei[self.rwind], wc.reference_weight()) zscore = wc.zscore_weight() + self.assertAlmostEqual(zscore, 38.049183673469386) + self.assertEqual(zscore, wc.expected_weight()) # test computation on mixed lab self.sub.lab = Lab.objects.get(name='mixed') self.sub.save() wc = self.sub.reinit_water_control() - self.assertAlmostEqual(self.wei[self.rwind], wc.reference_weight()) + self.assertAlmostEqual(expected, wc.reference_weight()) self.assertAlmostEqual(wc.expected_weight(), (wc.reference_weight() + zscore) / 2) # test that the thresholds are all above 70% - self.assertTrue(all([thrsh[0] > 0.4 for thrsh in wc.thresholds])) + self.assertTrue(all(thrsh[0] > 0.4 for thrsh in wc.thresholds)) # if we change the reference weight of the water restriction, this should change in wc too - self.assertAlmostEqual(wc.reference_weight(), self.wr.reference_weight) + self.assertAlmostEqual(expected, wc.reference_weight()) self.wr.reference_weight = self.wr.reference_weight + 1 self.wr.save() wc = self.sub.reinit_water_control() - self.assertAlmostEqual(wc.reference_weight(), self.wr.reference_weight) + self.assertAlmostEqual(expected + 1, wc.reference_weight()) class NotificationTests(TestCase): diff --git a/alyx/actions/tests_rest.py b/alyx/actions/tests_rest.py index 27531128f..efa168a56 100644 --- a/alyx/actions/tests_rest.py +++ b/alyx/actions/tests_rest.py @@ -7,7 +7,7 @@ from alyx.base import BaseTests from subjects.models import Subject, Project from misc.models import Lab, Note, ContentType -from actions.models import Session, WaterType, WaterAdministration +from actions.models import Session, WaterType, WaterAdministration, Surgery, ProcedureType from data.models import Dataset, DatasetType @@ -22,10 +22,13 @@ def setUp(self): self.lab02 = Lab.objects.create(name='awesomelab') self.projectX = Project.objects.create(name='projectX') self.projectY = Project.objects.create(name='projectY') - # Set an implant weight. - self.subject.implant_weight = 4.56 - self.subject.save() self.test_protocol = 'test_passoire' + # Create a surgery with an implant weight. + self.surgery = Surgery.objects.create( + subject=self.subject, implant_weight=4.56, start_time=now() - timedelta(days=7)) + implant_proc, _ = ProcedureType.objects.get_or_create(name='Headplate implant') + self.surgery.procedures.add(implant_proc.pk) + self.subject.save() def tearDown(self): base.DISABLE_MAIL = False @@ -305,9 +308,9 @@ def test_surgeries(self): sr = self.ar(self.client.get(reverse('surgeries-list',))) self.assertTrue(ns > 0) self.assertTrue(len(sr) == ns) - self.assertTrue(set(sr[0].keys()) == set( - ['id', 'subject', 'name', 'json', 'narrative', 'start_time', 'end_time', - 'outcome_type', 'lab', 'location', 'users', 'procedures'])) + self.assertTrue(set(sr[0].keys()) == { + 'id', 'subject', 'name', 'json', 'narrative', 'start_time', 'end_time', + 'outcome_type', 'lab', 'location', 'users', 'procedures', 'implant_weight'}) def test_list_retrieve_water_restrictions(self): url = reverse('water-restriction-list') diff --git a/alyx/actions/views.py b/alyx/actions/views.py index 2ecd72875..f14700b9b 100644 --- a/alyx/actions/views.py +++ b/alyx/actions/views.py @@ -1,4 +1,4 @@ -from datetime import timedelta, date +from datetime import timedelta, date, datetime from operator import itemgetter from one.alf.spec import QC @@ -457,7 +457,9 @@ def get(self, request, format=None, nickname=None): end_date = request.query_params.get('end_date', None) subject = Subject.objects.get(nickname=nickname) records = subject.water_control.to_jsonable(start_date=start_date, end_date=end_date) - data = {'subject': nickname, 'implant_weight': subject.implant_weight, + date_str = datetime.strptime(start_date, '%Y-%m-%d') if start_date else None + ref_iw = subject.water_control.reference_implant_weight_at(date_str) + data = {'subject': nickname, 'implant_weight': ref_iw, 'reference_weight_pct': subject.water_control.reference_weight_pct, 'zscore_weight_pct': subject.water_control.zscore_weight_pct, 'records': records} diff --git a/alyx/actions/water_control.py b/alyx/actions/water_control.py index ef6ad6852..326412da3 100644 --- a/alyx/actions/water_control.py +++ b/alyx/actions/water_control.py @@ -115,10 +115,9 @@ def tzone_convert(date_t, tz): return django.utils.timezone.make_naive(date_t, tz) -class WaterControl(object): +class WaterControl: def __init__(self, nickname=None, birth_date=None, sex=None, - implant_weight=None, subject_id=None, - reference_weight_pct=0., + subject_id=None, reference_weight_pct=0., zscore_weight_pct=0., timezone=django.utils.timezone.get_default_timezone(), ): @@ -129,10 +128,10 @@ def __init__(self, nickname=None, birth_date=None, sex=None, self.birth_date = to_date(birth_date) assert self.birth_date is None or isinstance(self.birth_date, datetime) self.sex = sex - self.implant_weight = implant_weight or 0. self.subject_id = subject_id self.water_restrictions = [] self.water_administrations = [] + self.implant_weights = [] self.weighings = [] self.reference_weighing = None self.reference_weight_pct = reference_weight_pct @@ -222,6 +221,10 @@ def water_restriction_at(self, date=None): assert s.date() <= date.date() return s + def add_implant_weight(self, date, weight): + """Add an implant weight.""" + self.implant_weights.append((tzone_convert(date, self.timezone), weight)) + def add_weighing(self, date, weighing): """Add a weighing.""" self.weighings.append((tzone_convert(date, self.timezone), weighing)) @@ -239,8 +242,21 @@ def add_threshold(self, percentage=None, bgcolor=None, fgcolor=None, line_style= self.thresholds.append((percentage, bgcolor, fgcolor, line_style)) self.thresholds[:] = sorted(self.thresholds, key=itemgetter(0)) + def reference_implant_weight_at(self, date=None, return_date=False): + """Return a the reference implant weight at the specified date. + + Returns a tuple (date, implant_weight) for the specified date (or today) + """ + date = date or self.today() + assert isinstance(date, datetime) + wr = self.water_restriction_at(date) # if exists, use date of water restriction + return self.last_implant_weight_before(wr or date, return_date) + def reference_weighing_at(self, date=None): - """Return a tuple (date, weight) the reference weighing at the specified date, or today.""" + """Return a tuple (date, weight) the reference weighing at the specified date, or today. + + NB: this does not account for changes in implant weight. + """ if self.reference_weighing and (date is None or date >= self.reference_weighing[0]): return self.reference_weighing date = date or self.today() @@ -258,28 +274,48 @@ def reference_weighing_at(self, date=None): return ref_weight def reference_weight(self, date=None): - """Return the reference weight at a given date.""" + """Return the reference weight at a given date. + + NB: Unlike `reference_weighing_at` this accounts for changes in implant + weight. + """ rw = self.reference_weighing_at(date=date) if rw: - return rw[1] + ref_iw = self.reference_implant_weight_at(date) or 0. + iw = self.last_implant_weight_before(date) or 0. + return (rw[1] - ref_iw) + iw return 0. + def last_implant_weight_before(self, date=None, return_date=False): + """Return the last known implant weight of the subject before the specified date.""" + date = date or self.today() + assert isinstance(date, datetime) + # Sort the weighings. + self.implant_weights[:] = sorted(self.implant_weights, key=itemgetter(0)) + weights_before = ( + (d, w) for (d, w) in reversed(self.implant_weights) if d.date() <= date.date() + ) + w = next(weights_before, None) + return w[1] if (w and not return_date) else w + def last_weighing_before(self, date=None): """Return the last known weight of the subject before the specified date.""" date = date or self.today() assert isinstance(date, datetime) # Sort the weighings. self.weighings[:] = sorted(self.weighings, key=itemgetter(0)) - weighings_before = [(d, w) for (d, w) in self.weighings if d.date() <= date.date()] - if weighings_before: - return weighings_before[-1] + weighings_before = ( + (d, w) for (d, w) in reversed(self.weighings) if d.date() <= date.date() + ) + return next(weighings_before, None) def weighing_at(self, date=None): """Return the weight of the subject at the specified date.""" date = date or self.today() assert isinstance(date, datetime) - weighings_at = [(d, w) for (d, w) in self.weighings if d.date() == date.date()] - return weighings_at[0][1] if weighings_at else None + weighings_at = ((d, w) for (d, w) in reversed(self.weighings) if d.date() == date.date()) + weighing = next(weighings_at, None) + return weighing[1] if weighing else None def current_weighing(self): """Return the last known weight.""" @@ -290,6 +326,10 @@ def weight(self, date=None): cw = self.last_weighing_before(date=date) return cw[1] if cw else 0 + def implant_weight(self, date=None): + """Return current implant weight.""" + return self.last_implant_weight_before(date=date) + def zscore_weight(self, date=None): """Return the expected zscored weight at the specified date.""" date = date or self.today() @@ -297,7 +337,8 @@ def zscore_weight(self, date=None): if not rw: return 0 ref_date, ref_weight = rw - iw = self.implant_weight + ref_iw = self.reference_implant_weight_at(date) or 0. + iw = self.last_implant_weight_before(date) or 0. if not self.birth_date: logger.warning("The birth date of %s has not been specified.", self.nickname) return 0 @@ -307,7 +348,7 @@ def zscore_weight(self, date=None): # Expected mean/std at that time. mrw_ref, srw_ref = expected_weighing_mean_std(self.sex, age_ref) # z-score. - zscore = (ref_weight - iw - mrw_ref) / srw_ref + zscore = (ref_weight - ref_iw - mrw_ref) / srw_ref # Expected weight. mrw_date, srw_date = expected_weighing_mean_std(self.sex, age_date) return (srw_date * zscore) + mrw_date + iw @@ -331,7 +372,7 @@ def percentage_weight(self, date=None): """ date = date or self.today() - iw = self.implant_weight or 0. + iw = self.last_implant_weight_before(date) or 0. w = self.weight(date=date) e = self.expected_weight(date=date) return 100 * (w - iw) / (e - iw) if (e - iw) > 0 else 0. @@ -371,19 +412,21 @@ def last_water_administration_at(self, date=None): date = date or self.today() # Sort the water administrations. self.water_administrations[:] = sorted(self.water_administrations, key=itemgetter(0)) - wa_before = [(d, w, h) for (d, w, h) in self.water_administrations if d <= date] - if wa_before: - return wa_before[-1] + wa_before = ((d, w, h) for (d, w, h) in reversed(self.water_administrations) if d <= date) + return next(wa_before, None) def expected_water(self, date=None): """Return the expected water for the specified date.""" date = date or self.today() assert isinstance(date, datetime) - iw = self.implant_weight or 0. + iw = self.last_implant_weight_before(date) or 0. weight = self.last_weighing_before(date=date) weight = weight[1] if weight else 0. expected_weight = self.expected_weight(date=date) or 0. - return 0.05 * (weight - iw) if weight < 0.8 * expected_weight else 0.04 * (weight - iw) + pct_sum = (self.reference_weight_pct + self.zscore_weight_pct) + # Increase required water by 0.01mL/g if weight below pct reference + pct_weight = (pct_sum * expected_weight) + return 0.05 * (weight - iw) if weight < pct_weight else 0.04 * (weight - iw) def given_water(self, date=None, has_session=None): """Return the amount of water given at a specified date.""" @@ -433,6 +476,7 @@ def excess_water(self, date=None): 'expected_water', 'excess_water', 'is_water_restricted', + 'implant_weight' ) def weight_status(self, date=None): @@ -530,7 +574,7 @@ def plot(self, start=None, end=None): ax.set_xlim(start, end) eq = 'weight > %.1f*ref + %.1f*zscore' % ( self.reference_weight_pct, self.zscore_weight_pct) - ax.set_title("Weighings for %s (%s)" % (self.nickname, eq)) + ax.set_title('Weighings for %s (%s)' % (self.nickname, eq)) ax.set_xlabel('Date') ax.set_ylabel('Weight (g)') ax.legend(loc=2) @@ -560,8 +604,7 @@ def water_control(subject): reference_weight_pct=rw_pct, zscore_weight_pct=zw_pct, timezone=subject.timezone(), - subject_id=subject.id, - implant_weight=subject.implant_weight + subject_id=subject.id ) wc.add_threshold(percentage=rw_pct + zw_pct, bgcolor=PALETTE['orange'], fgcolor='#FFC28E') if absolute_min := settings.WEIGHT_THRESHOLD: @@ -569,10 +612,17 @@ def water_control(subject): percentage=absolute_min, bgcolor=PALETTE['red'], fgcolor='#F08699', line_style='--') # Water restrictions. wrs = sorted(list(subject.actions_waterrestrictions.all()), key=attrgetter('start_time')) + # Surgeries. + srgs = sorted(list(subject.actions_surgerys.all()), key=attrgetter('start_time')) + for srg in srgs: + iw = srg.implant_weight + if iw: + wc.add_implant_weight(srg.start_time, iw) # Reference weight. last_wr = wrs[-1] if wrs else None - if last_wr and last_wr.reference_weight: - wc.set_reference_weight(last_wr.start_time, last_wr.reference_weight) + if last_wr: + if last_wr.reference_weight: + wc.set_reference_weight(last_wr.start_time, last_wr.reference_weight) for wr in wrs: wc.add_water_restriction(wr.start_time, wr.end_time, wr.reference_weight) diff --git a/alyx/alyx/settings_lab_template.py b/alyx/alyx/settings_lab_template.py index 29c2e8368..9c10fabbd 100644 --- a/alyx/alyx/settings_lab_template.py +++ b/alyx/alyx/settings_lab_template.py @@ -3,7 +3,7 @@ # ALYX-SPECIFIC ALLOWED_HOSTS = ['localhost', '127.0.0.1'] LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'Europe/London' +TIME_ZONE = 'Europe/London' # NB: Changing the timezone here requires migrations GLOBUS_CLIENT_ID = '525cc543-8ccb-4d11-8036-af332da5eafd' SUBJECT_REQUEST_EMAIL_FROM = 'alyx@internationalbrainlab.org' DEFAULT_SOURCE = 'IBL' diff --git a/alyx/experiments/serializers.py b/alyx/experiments/serializers.py index 9e72f688d..92ad06a85 100644 --- a/alyx/experiments/serializers.py +++ b/alyx/experiments/serializers.py @@ -46,6 +46,15 @@ class TrajectoryEstimateSerializer(serializers.ModelSerializer): queryset=CoordinateSystem.objects.all(), ) + def to_internal_value(self, data): + if data.get('chronic_insertion', None) is None: + data['chronic_insertion'] = None + + if data.get('probe_insertion', None) is None: + data['probe_insertion'] = None + + return super(TrajectoryEstimateSerializer, self).to_internal_value(data) + class Meta: model = TrajectoryEstimate fields = '__all__' diff --git a/alyx/experiments/tests_rest.py b/alyx/experiments/tests_rest.py index 412a4a6b6..eac771f4b 100644 --- a/alyx/experiments/tests_rest.py +++ b/alyx/experiments/tests_rest.py @@ -2,7 +2,6 @@ from django.contrib.auth import get_user_model from django.urls import reverse -from django.core.management import call_command from django.db import transaction from alyx.base import BaseTests @@ -15,9 +14,9 @@ class APIProbeExperimentTests(BaseTests): + fixtures = ['experiments.brainregion.json', 'experiments.probemodel.json'] + def setUp(self): - call_command('loaddata', 'experiments/fixtures/experiments.probemodel.json', verbosity=0) - call_command('loaddata', 'experiments/fixtures/experiments.brainregion.json', verbosity=0) self.superuser = get_user_model().objects.create_superuser('test', 'test', 'test') self.client.login(username='test', password='test') self.session = Session.objects.first() @@ -401,12 +400,9 @@ def test_dataset_filters(self): class APIImagingExperimentTests(BaseTests): + fixtures = ['experiments.brainregion.json', 'experiments.coordinatesystem.json'] def setUp(self): - call_command('loaddata', 'experiments/fixtures/experiments.brainregion.json', verbosity=0) - call_command( - 'loaddata', 'experiments/fixtures/experiments.coordinatesystem.json', verbosity=0 - ) self.superuser = get_user_model().objects.create_superuser('test', 'test', 'test') self.client.login(username='test', password='test') # self.session = Session.objects.first() diff --git a/alyx/subjects/admin.py b/alyx/subjects/admin.py index 40b7125ba..f232a326f 100755 --- a/alyx/subjects/admin.py +++ b/alyx/subjects/admin.py @@ -220,7 +220,7 @@ class SurgeryInline(BaseInlineAdmin): model = Surgery extra = 1 fields = ['procedures', 'narrative', 'start_time', 'end_time', 'outcome_type', - 'users', 'location'] + 'users', 'location', 'implant_weight'] readonly_fields = fields classes = ['collapse'] show_change_link = True @@ -334,9 +334,10 @@ class SubjectAdmin(BaseAdmin): ('OUTCOMES', {'fields': ('cull_method', 'adverse_effects', 'actual_severity'), 'classes': ('collapse',), }), - ('WEIGHINGS/WATER', {'fields': ('implant_weight', - 'current_weight', + ('WEIGHINGS/WATER', {'fields': ('current_weight', + 'current_implant_weight', 'reference_weight', + 'reference_implant_weight', 'expected_weight', 'given_water', 'expected_water', @@ -365,7 +366,7 @@ class SubjectAdmin(BaseAdmin): 'breeding_pair_l', 'litter_l', 'line_l', 'cage_changes', 'cull_', 'cull_reason_', 'death_date', - ) + fieldsets[4][1]['fields'][1:] + HOUSING_FIELDS # water read only fields + ) + fieldsets[4][1]['fields'] + HOUSING_FIELDS # water read only fields ordering = ['-birth_date', '-nickname'] list_editable = [] list_filter = [ResponsibleUserListFilter, @@ -477,6 +478,10 @@ def project_l(self, obj): def zygosities(self, obj): return '; '.join(obj.zygosity_strings()) + def reference_implant_weight(self, obj): + res = obj.water_control.reference_implant_weight + return '%.2f' % res[1] if res else '0' + def reference_weight(self, obj): res = obj.water_control.reference_weighing_at() return '%.2f' % res[1] if res else '0' @@ -485,6 +490,10 @@ def current_weight(self, obj): res = obj.water_control.current_weighing() return '%.2f' % res[1] if res else '0' + def current_implant_weight(self, obj): + res = obj.water_control.implant_weight() or 0. + return '%.2f' % res + def expected_weight(self, obj): res = obj.water_control.expected_weight() return '%.2f' % res if res else '0' diff --git a/alyx/subjects/migrations/0013_remove_subject_implant_weight.py b/alyx/subjects/migrations/0013_remove_subject_implant_weight.py new file mode 100644 index 000000000..71bfaf1b1 --- /dev/null +++ b/alyx/subjects/migrations/0013_remove_subject_implant_weight.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.10 on 2024-03-15 13:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('subjects', '0012_alter_subject_nickname'), + ('actions', '0024_surgery_implant_weight'), + ] + + operations = [ + migrations.RemoveField( + model_name='subject', + name='implant_weight', + ), + ] diff --git a/alyx/subjects/models.py b/alyx/subjects/models.py index 50e3dd56d..f4bfcfeaf 100644 --- a/alyx/subjects/models.py +++ b/alyx/subjects/models.py @@ -181,7 +181,6 @@ class Subject(BaseModel): cage = models.CharField(max_length=64, null=True, blank=True) request = models.ForeignKey('SubjectRequest', null=True, blank=True, on_delete=models.SET_NULL) - implant_weight = models.FloatField(null=True, blank=True, help_text="Implant weight in grams") ear_mark = models.CharField(max_length=32, blank=True) protocol_number = models.CharField(max_length=1, choices=PROTOCOL_NUMBERS, default=settings.DEFAULT_PROTOCOL) diff --git a/alyx/subjects/tests.py b/alyx/subjects/tests.py index 154a8e508..b63b05a1b 100644 --- a/alyx/subjects/tests.py +++ b/alyx/subjects/tests.py @@ -260,7 +260,7 @@ def test_protocol_number(self): assert self.sub.protocol_number == '1' # after a surgery protocol number goes to 2 self.surgery = Surgery.objects.create( - subject=self.sub, start_time=datetime(2019, 1, 1, 12, 0, 0)) + subject=self.sub, start_time=datetime(2019, 1, 1, 12, 0, 0), implant_weight=0.) assert self.sub.protocol_number == '2' # after water restriction number goes to 3 self.wr = WaterRestriction.objects.create( diff --git a/alyx/templates/water_history.html b/alyx/templates/water_history.html index 2065ebe07..3d570ab39 100644 --- a/alyx/templates/water_history.html +++ b/alyx/templates/water_history.html @@ -32,6 +32,7 @@ water tot water exp water excess + implant weight @@ -48,6 +49,7 @@ {{ obj.given_water_total | floatformat:2 }} mL {{ obj.expected_water | floatformat:2 }} mL {{ obj.excess_water | floatformat:2 }} mL + {% if obj.implant_weight %} {{ obj.implant_weight | floatformat:1 }} {% else %} 0.0 {% endif %} g {% endfor %} diff --git a/data/all_dumped_anon.json.gz b/data/all_dumped_anon.json.gz index 9e9722069..be7b38329 100644 Binary files a/data/all_dumped_anon.json.gz and b/data/all_dumped_anon.json.gz differ diff --git a/scripts/oneoff/2024-03-16-update_test_fixture.py b/scripts/oneoff/2024-03-16-update_test_fixture.py new file mode 100644 index 000000000..67a6ff146 --- /dev/null +++ b/scripts/oneoff/2024-03-16-update_test_fixture.py @@ -0,0 +1,43 @@ +"""Move implant weight in test fixtures.""" +import gzip +import json +import random +from pathlib import Path + +# Fixture file +path = Path(__file__).parents[2].joinpath('data', 'all_dumped_anon.json.gz') +if not path.exists(): + raise FileNotFoundError + +# Load and parse fixture +with gzip.open(path, 'rb') as fp: + data = json.load(fp) + +# Get implant weight map +pk2iw = {r['pk']: r['fields']['implant_weight'] for r in filter(lambda r: r['model'] == 'subjects.subject', data)} + +# Add implant weights to surgeries +for record in filter(lambda r: r['model'] == 'actions.surgery', data): + # Check if implant surgery + implant = any('implant' in p for p in record['fields'].get('procedures', [])) or 'headplate' in record['fields']['narrative'] + # Implant weight should be subject's implant weight + iw = pk2iw[record['fields']['subject']] + if iw is None: # ... or a random float rounded to 2 decimal places + iw = float(f'{random.randint(15, 20) + random.random():.2f}') + # If not implant surgery, set to 0, otherwise use above weight + record['fields'].update(implant_weight=iw if implant else 0.) + +# Remove implant weights from subjects +for record in filter(lambda r: r['model'] == 'subjects.subject', data): + record['fields'].pop('implant_weight') + +# find any with multiple surgeries +# from collections import Counter +# counter = Counter(map(lambda r: r['fields']['subject'], filter(lambda r: r['model'] == 'actions.surgery', data))) +# pk, total = counter.most_common()[3] +# assert total > 1 +# records = list(filter(lambda r: r['model'] == 'actions.surgery' and r['fields']['subject'] == pk, data)) + +# Write to file +with gzip.open(path, 'wt', encoding='UTF-8') as fp: + json.dump(data, fp, indent=2)