Skip to content

Commit 48701ad

Browse files
authored
Merge pull request #725 from cortex-lab/dev
Release 1.0.0
2 parents 6a62275 + 0c52378 commit 48701ad

25 files changed

+361
-108
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,5 @@ alyx/alyx/settings_lab.py
3131
alyx/alyx/settings.py
3232

3333
alyx/.idea/
34+
35+
alyx.log

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Alyx has only been tested on Ubuntu (16.04 / 18.04 / 20.04), the latest is recom
1313
this setup will work on other systems. Assumptions made are that you have sudo permissions under an account named
1414
`ubuntu`.
1515

16-
## Install apache, wsgi module, and set group and acl permissions
16+
### Install apache, wsgi module, and set group and acl permissions
1717
sudo apt-get update
1818
sudo apt-get install apache2 libapache2-mod-wsgi-py3 acl
1919
sudo a2enmod wsgi
@@ -78,6 +78,27 @@ Location of error logs for apache if it fails to start
7878

7979
/var/log/apache2/
8080

81+
### [Optional] Setup AWS Cloudwatch Agent logging
82+
83+
If you are running alyx as an EC2 instance on AWS, you can easily add the AWS Cloudwatch agent to the server to ease log
84+
evaluation and alerting. This can also be done with a non-ec2 server, but is likely not worth it unless you are already
85+
using Cloudwatch for other logs.
86+
87+
To give an overview of the installation process for an EC2 instance:
88+
* Create an IAM role that enables the agent to collect metrics from the server and attach the role to the server.
89+
* Download the agent package to the instance.
90+
* Modify the CloudWatch agent configuration file, specify the metrics and the log files that you want to collect.
91+
* Install and start the agent on your server.
92+
* Verify in Cloudwatch
93+
* you are now able to generate alerts from the metrics of interest
94+
* you are now shipping the logs files to your log group
95+
96+
Follow the latest instructions from the official [AWS Cloudwatch Agent documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Install-CloudWatch-Agent.html).
97+
98+
Other useful references:
99+
* [IAM documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html)
100+
* [EC2 metadata documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html)
101+
81102
---
82103

83104
### [macOS] Local installation of alyx

alyx/actions/admin.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ def __init__(self, *args, **kwargs):
118118
self.fields['users'].queryset = get_user_model().objects.all().order_by('username')
119119
if 'user' in self.fields:
120120
self.fields['user'].queryset = get_user_model().objects.all().order_by('username')
121-
if 'subject' in self.fields:
121+
# restricts the subject choices only to managed subjects
122+
if 'subject' in self.fields and not (
123+
self.current_user.is_stock_manager or self.current_user.is_superuser):
122124
inst = self.instance
123125
ids = [s.id for s in Subject.objects.filter(responsible_user=self.current_user,
124126
cull__isnull=True).order_by('nickname')]
@@ -356,6 +358,14 @@ def given_water_total(self, obj):
356358
return '%.2f' % obj.subject.water_control.given_water_total()
357359
given_water_total.short_description = 'water tot'
358360

361+
def has_change_permission(self, request, obj=None):
362+
# setting to override edition of water restrictions in the settings.lab file
363+
override = getattr(settings, 'WATER_RESTRICTIONS_EDITABLE', False)
364+
if override:
365+
return True
366+
else:
367+
return super(WaterRestrictionAdmin, self).has_change_permission(request, obj=obj)
368+
359369
def expected_water(self, obj):
360370
if not obj.subject:
361371
return
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated by Django 4.0.3 on 2022-03-30 15:46
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('subjects', '0010_auto_20210624_1253'),
11+
('actions', '0016_chronicrecording'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='chronicrecording',
17+
name='subject',
18+
field=models.ForeignKey(help_text='The subject on which this action was performed', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='subjects.subject'),
19+
),
20+
migrations.AlterField(
21+
model_name='otheraction',
22+
name='subject',
23+
field=models.ForeignKey(help_text='The subject on which this action was performed', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='subjects.subject'),
24+
),
25+
migrations.AlterField(
26+
model_name='session',
27+
name='subject',
28+
field=models.ForeignKey(help_text='The subject on which this action was performed', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='subjects.subject'),
29+
),
30+
migrations.AlterField(
31+
model_name='surgery',
32+
name='subject',
33+
field=models.ForeignKey(help_text='The subject on which this action was performed', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='subjects.subject'),
34+
),
35+
migrations.AlterField(
36+
model_name='virusinjection',
37+
name='subject',
38+
field=models.ForeignKey(help_text='The subject on which this action was performed', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='subjects.subject'),
39+
),
40+
migrations.AlterField(
41+
model_name='waterrestriction',
42+
name='subject',
43+
field=models.ForeignKey(help_text='The subject on which this action was performed', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='subjects.subject'),
44+
),
45+
]

alyx/actions/models.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,16 +209,20 @@ class Meta:
209209

210210
def save(self, *args, **kwargs):
211211
# Issue #422.
212-
if self.subject.protocol_number == '1':
213-
self.subject.protocol_number = '3'
214-
# Change from mild to moderate.
212+
output = super(Surgery, self).save(*args, **kwargs)
213+
self.subject.set_protocol_number()
215214
if self.subject.actual_severity == 2:
216215
self.subject.actual_severity = 3
217-
218216
if self.outcome_type == 'a' and self.start_time:
219217
self.subject.death_date = self.start_time
220218
self.subject.save()
221-
return super(Surgery, self).save(*args, **kwargs)
219+
return output
220+
221+
def delete(self, *args, **kwargs):
222+
output = super(Surgery, self).delete(*args, **kwargs)
223+
self.subject.set_protocol_number()
224+
self.subject.save()
225+
return output
222226

223227

224228
class Session(BaseAction):
@@ -314,14 +318,28 @@ class WaterRestriction(BaseAction):
314318
def is_active(self):
315319
return self.start_time is not None and self.end_time is None
316320

321+
def delete(self, *args, **kwargs):
322+
output = super(WaterRestriction, self).delete(*args, **kwargs)
323+
self.subject.reinit_water_control()
324+
self.subject.set_protocol_number()
325+
self.subject.save()
326+
return output
327+
317328
def save(self, *args, **kwargs):
318329
if not self.reference_weight and self.subject:
319330
w = self.subject.water_control.last_weighing_before(self.start_time)
320331
if w:
321332
self.reference_weight = w[1]
322333
# makes sure the closest weighing is one week around, break if not
323334
assert(abs(w[0] - self.start_time) < timedelta(days=7))
324-
return super(WaterRestriction, self).save(*args, **kwargs)
335+
output = super(WaterRestriction, self).save(*args, **kwargs)
336+
# When creating a water restriction, the subject's protocol number should be changed to 3
337+
# (request by Charu in 03/2022)
338+
if self.subject:
339+
self.subject.reinit_water_control()
340+
self.subject.set_protocol_number()
341+
self.subject.save()
342+
return output
325343

326344

327345
class OtherAction(BaseAction):
@@ -543,13 +561,13 @@ def save(self, *args, **kwargs):
543561
self.subject.cull_method = str(self.cull_method)
544562
subject_change = True
545563
if subject_change:
546-
self.subject.save()
547564
# End all open water restrictions.
548565
for wr in WaterRestriction.objects.filter(
549566
subject=self.subject, start_time__isnull=False, end_time__isnull=True):
550567
wr.end_time = self.date
551568
logger.debug("Ending water restriction %s.", wr)
552569
wr.save()
570+
self.subject.save()
553571
return super(Cull, self).save(*args, **kwargs)
554572

555573
def delete(self, *args, **kwargs):

alyx/actions/views.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212
from django.views.generic.list import ListView
1313

1414
import django_filters
15-
from rest_framework import generics, permissions
15+
from rest_framework import generics
1616
from rest_framework.response import Response
1717
from rest_framework.views import APIView
1818

19-
from alyx.base import base_json_filter, BaseFilterSet
19+
from alyx.base import base_json_filter, BaseFilterSet, rest_permission_classes
2020
from subjects.models import Subject
2121
from experiments.views import _filter_qs_with_brain_regions
2222
from .water_control import water_control, to_date
@@ -332,7 +332,8 @@ class SessionAPIList(generics.ListCreateAPIView):
332332
"""
333333
queryset = Session.objects.all()
334334
queryset = SessionListSerializer.setup_eager_loading(queryset)
335-
permission_classes = (permissions.IsAuthenticated,)
335+
permission_classes = rest_permission_classes()
336+
336337
filter_class = SessionFilter
337338

338339
def get_serializer_class(self):
@@ -351,14 +352,14 @@ class SessionAPIDetail(generics.RetrieveUpdateDestroyAPIView):
351352
queryset = Session.objects.all().order_by('-start_time')
352353
queryset = SessionDetailSerializer.setup_eager_loading(queryset)
353354
serializer_class = SessionDetailSerializer
354-
permission_classes = (permissions.IsAuthenticated,)
355+
permission_classes = rest_permission_classes()
355356

356357

357358
class WeighingAPIListCreate(generics.ListCreateAPIView):
358359
"""
359360
Lists or creates a new weighing.
360361
"""
361-
permission_classes = (permissions.IsAuthenticated,)
362+
permission_classes = rest_permission_classes()
362363
serializer_class = WeighingDetailSerializer
363364
queryset = Weighing.objects.all()
364365
queryset = WeighingDetailSerializer.setup_eager_loading(queryset)
@@ -369,23 +370,23 @@ class WeighingAPIDetail(generics.RetrieveDestroyAPIView):
369370
"""
370371
Allows viewing of full detail and deleting a weighing.
371372
"""
372-
permission_classes = (permissions.IsAuthenticated,)
373+
permission_classes = rest_permission_classes()
373374
serializer_class = WeighingDetailSerializer
374375
queryset = Weighing.objects.all()
375376

376377

377378
class WaterTypeList(generics.ListCreateAPIView):
378379
queryset = WaterType.objects.all()
379380
serializer_class = WaterTypeDetailSerializer
380-
permission_classes = (permissions.IsAuthenticated,)
381+
permission_classes = rest_permission_classes()
381382
lookup_field = 'name'
382383

383384

384385
class WaterAdministrationAPIListCreate(generics.ListCreateAPIView):
385386
"""
386387
Lists or creates a new water administration.
387388
"""
388-
permission_classes = (permissions.IsAuthenticated,)
389+
permission_classes = rest_permission_classes()
389390
serializer_class = WaterAdministrationDetailSerializer
390391
queryset = WaterAdministration.objects.all()
391392
queryset = WaterAdministrationDetailSerializer.setup_eager_loading(queryset)
@@ -396,7 +397,7 @@ class WaterAdministrationAPIDetail(generics.RetrieveUpdateDestroyAPIView):
396397
"""
397398
Allows viewing of full detail and deleting a water administration.
398399
"""
399-
permission_classes = (permissions.IsAuthenticated,)
400+
permission_classes = rest_permission_classes()
400401
serializer_class = WaterAdministrationDetailSerializer
401402
queryset = WaterAdministration.objects.all()
402403

@@ -413,7 +414,7 @@ def _merge_lists_dicts(la, lb, key):
413414

414415

415416
class WaterRequirement(APIView):
416-
permission_classes = (permissions.IsAuthenticated,)
417+
permission_classes = rest_permission_classes()
417418

418419
def get(self, request, format=None, nickname=None):
419420
assert nickname
@@ -439,7 +440,7 @@ class WaterRestrictionList(generics.ListAPIView):
439440
"""
440441
queryset = WaterRestriction.objects.all().order_by('-end_time', '-start_time')
441442
serializer_class = WaterRestrictionListSerializer
442-
permission_classes = (permissions.IsAuthenticated,)
443+
permission_classes = rest_permission_classes()
443444
filter_class = WaterRestrictionFilter
444445

445446

@@ -449,14 +450,14 @@ class LabLocationList(generics.ListAPIView):
449450
"""
450451
queryset = LabLocation.objects.all()
451452
serializer_class = LabLocationSerializer
452-
permission_classes = (permissions.IsAuthenticated,)
453+
permission_classes = rest_permission_classes()
453454

454455

455456
class LabLocationAPIDetails(generics.RetrieveUpdateAPIView):
456457
"""
457458
Allows viewing of full detail and deleting a water administration.
458459
"""
459-
permission_classes = (permissions.IsAuthenticated,)
460+
permission_classes = rest_permission_classes()
460461
serializer_class = LabLocationSerializer
461462
queryset = LabLocation.objects.all()
462463
lookup_field = 'name'
@@ -468,4 +469,4 @@ class SurgeriesList(generics.ListAPIView):
468469
"""
469470
queryset = Surgery.objects.all()
470471
serializer_class = SurgerySerializer
471-
permission_classes = (permissions.IsAuthenticated,)
472+
permission_classes = rest_permission_classes()

alyx/alyx/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
__version__ = '1.0.0'
2+
VERSION = __version__ # synonym

alyx/alyx/base.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424
from django_filters.rest_framework import FilterSet
2525
from rest_framework.views import exception_handler
2626

27+
from rest_framework import permissions
2728
from dateutil.parser import parse
2829
from reversion.admin import VersionAdmin
29-
30+
from alyx import __version__ as version
3031

3132
logger = structlog.get_logger(__name__)
3233

@@ -316,13 +317,28 @@ def changelist_view(self, request, extra_context=None):
316317
for model in category_list[0].models]
317318
return super(BaseAdmin, self).changelist_view(request, extra_context=extra_context)
318319

320+
def has_add_permission(self, request, *args, **kwargs):
321+
if request.user.is_public_user:
322+
return False
323+
else:
324+
return super(BaseAdmin, self).has_add_permission(request, *args, **kwargs)
325+
319326
def has_change_permission(self, request, obj=None):
327+
if request.user.is_public_user:
328+
return False
320329
if not obj:
321330
return True
322331
if request.user.is_superuser:
323332
return True
324-
# Subject associated to the object.
325-
subj = obj if hasattr(obj, 'responsible_user') else getattr(obj, 'subject', None)
333+
# Find subject associated to the object.
334+
if hasattr(obj, 'responsible_user'):
335+
subj = obj
336+
elif getattr(obj, 'session', None):
337+
subj = obj.session.subject
338+
elif getattr(obj, 'subject', None):
339+
subj = obj.subject
340+
else:
341+
return False
326342
resp_user = getattr(subj, 'responsible_user', None)
327343
# List of allowed users for the subject.
328344
allowed = getattr(resp_user, 'allowed_users', None)
@@ -562,11 +578,29 @@ def rest_filters_exception_handler(exc, context):
562578
return response
563579

564580

581+
class BaseRestPublicPermission(permissions.BasePermission):
582+
"""
583+
The purpose is to prevent public users from interfering in any way using writable methods
584+
"""
585+
def has_permission(self, request, view):
586+
if request.method == 'GET':
587+
return True
588+
elif request.user.is_public_user:
589+
return False
590+
else:
591+
return True
592+
593+
594+
def rest_permission_classes():
595+
permission_classes = (permissions.IsAuthenticated & BaseRestPublicPermission,)
596+
return permission_classes
597+
598+
565599
mysite = MyAdminSite()
566600
mysite.site_header = 'Alyx'
567601
mysite.site_title = 'Alyx'
568602
mysite.site_url = None
569-
mysite.index_title = 'Welcome to Alyx'
603+
mysite.index_title = f'Welcome to Alyx {version}'
570604
mysite.enable_nav_sidebar = False
571605

572606
admin.site = mysite

alyx/alyx/settings_lab_template.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
STOCK_MANAGERS = ('root',)
1313
WEIGHT_THRESHOLD = 0.75
1414
DEFAULT_LAB_NAME = 'defaultlab'
15+
WATER_RESTRICTIONS_EDITABLE = False # if set to True, all users can edit water restrictions
1516
DEFAULT_LAB_PK = '4027da48-7be3-43ec-a222-f75dffe36872'
1617
SESSION_REPO_URL = \
1718
"http://ibl.flatironinstitute.org/{lab}/Subjects/{subject}/{date}/{number:03d}/"

0 commit comments

Comments
 (0)