Skip to content

Commit

Permalink
Merge pull request #725 from cortex-lab/dev
Browse files Browse the repository at this point in the history
Release 1.0.0
  • Loading branch information
oliche authored Apr 4, 2022
2 parents 6a62275 + 0c52378 commit 48701ad
Show file tree
Hide file tree
Showing 25 changed files with 361 additions and 108 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ alyx/alyx/settings_lab.py
alyx/alyx/settings.py

alyx/.idea/

alyx.log
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Alyx has only been tested on Ubuntu (16.04 / 18.04 / 20.04), the latest is recom
this setup will work on other systems. Assumptions made are that you have sudo permissions under an account named
`ubuntu`.

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

/var/log/apache2/

### [Optional] Setup AWS Cloudwatch Agent logging

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
evaluation and alerting. This can also be done with a non-ec2 server, but is likely not worth it unless you are already
using Cloudwatch for other logs.

To give an overview of the installation process for an EC2 instance:
* Create an IAM role that enables the agent to collect metrics from the server and attach the role to the server.
* Download the agent package to the instance.
* Modify the CloudWatch agent configuration file, specify the metrics and the log files that you want to collect.
* Install and start the agent on your server.
* Verify in Cloudwatch
* you are now able to generate alerts from the metrics of interest
* you are now shipping the logs files to your log group

Follow the latest instructions from the official [AWS Cloudwatch Agent documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Install-CloudWatch-Agent.html).

Other useful references:
* [IAM documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html)
* [EC2 metadata documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html)

---

### [macOS] Local installation of alyx
Expand Down
12 changes: 11 additions & 1 deletion alyx/actions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ def __init__(self, *args, **kwargs):
self.fields['users'].queryset = get_user_model().objects.all().order_by('username')
if 'user' in self.fields:
self.fields['user'].queryset = get_user_model().objects.all().order_by('username')
if 'subject' in self.fields:
# restricts the subject choices only to managed subjects
if 'subject' in self.fields and not (
self.current_user.is_stock_manager or self.current_user.is_superuser):
inst = self.instance
ids = [s.id for s in Subject.objects.filter(responsible_user=self.current_user,
cull__isnull=True).order_by('nickname')]
Expand Down Expand Up @@ -356,6 +358,14 @@ def given_water_total(self, obj):
return '%.2f' % obj.subject.water_control.given_water_total()
given_water_total.short_description = 'water tot'

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)
if override:
return True
else:
return super(WaterRestrictionAdmin, self).has_change_permission(request, obj=obj)

def expected_water(self, obj):
if not obj.subject:
return
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Generated by Django 4.0.3 on 2022-03-30 15:46

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('subjects', '0010_auto_20210624_1253'),
('actions', '0016_chronicrecording'),
]

operations = [
migrations.AlterField(
model_name='chronicrecording',
name='subject',
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'),
),
migrations.AlterField(
model_name='otheraction',
name='subject',
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'),
),
migrations.AlterField(
model_name='session',
name='subject',
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'),
),
migrations.AlterField(
model_name='surgery',
name='subject',
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'),
),
migrations.AlterField(
model_name='virusinjection',
name='subject',
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'),
),
migrations.AlterField(
model_name='waterrestriction',
name='subject',
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'),
),
]
32 changes: 25 additions & 7 deletions alyx/actions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,16 +209,20 @@ class Meta:

def save(self, *args, **kwargs):
# Issue #422.
if self.subject.protocol_number == '1':
self.subject.protocol_number = '3'
# Change from mild to moderate.
output = super(Surgery, self).save(*args, **kwargs)
self.subject.set_protocol_number()
if self.subject.actual_severity == 2:
self.subject.actual_severity = 3

if self.outcome_type == 'a' and self.start_time:
self.subject.death_date = self.start_time
self.subject.save()
return super(Surgery, self).save(*args, **kwargs)
return output

def delete(self, *args, **kwargs):
output = super(Surgery, self).delete(*args, **kwargs)
self.subject.set_protocol_number()
self.subject.save()
return output


class Session(BaseAction):
Expand Down Expand Up @@ -314,14 +318,28 @@ class WaterRestriction(BaseAction):
def is_active(self):
return self.start_time is not None and self.end_time is None

def delete(self, *args, **kwargs):
output = super(WaterRestriction, self).delete(*args, **kwargs)
self.subject.reinit_water_control()
self.subject.set_protocol_number()
self.subject.save()
return output

def save(self, *args, **kwargs):
if not self.reference_weight and self.subject:
w = self.subject.water_control.last_weighing_before(self.start_time)
if w:
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))
return super(WaterRestriction, self).save(*args, **kwargs)
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)
if self.subject:
self.subject.reinit_water_control()
self.subject.set_protocol_number()
self.subject.save()
return output


class OtherAction(BaseAction):
Expand Down Expand Up @@ -543,13 +561,13 @@ def save(self, *args, **kwargs):
self.subject.cull_method = str(self.cull_method)
subject_change = True
if subject_change:
self.subject.save()
# End all open water restrictions.
for wr in WaterRestriction.objects.filter(
subject=self.subject, start_time__isnull=False, end_time__isnull=True):
wr.end_time = self.date
logger.debug("Ending water restriction %s.", wr)
wr.save()
self.subject.save()
return super(Cull, self).save(*args, **kwargs)

def delete(self, *args, **kwargs):
Expand Down
29 changes: 15 additions & 14 deletions alyx/actions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
from django.views.generic.list import ListView

import django_filters
from rest_framework import generics, permissions
from rest_framework import generics
from rest_framework.response import Response
from rest_framework.views import APIView

from alyx.base import base_json_filter, BaseFilterSet
from alyx.base import base_json_filter, BaseFilterSet, rest_permission_classes
from subjects.models import Subject
from experiments.views import _filter_qs_with_brain_regions
from .water_control import water_control, to_date
Expand Down Expand Up @@ -332,7 +332,8 @@ class SessionAPIList(generics.ListCreateAPIView):
"""
queryset = Session.objects.all()
queryset = SessionListSerializer.setup_eager_loading(queryset)
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()

filter_class = SessionFilter

def get_serializer_class(self):
Expand All @@ -351,14 +352,14 @@ class SessionAPIDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Session.objects.all().order_by('-start_time')
queryset = SessionDetailSerializer.setup_eager_loading(queryset)
serializer_class = SessionDetailSerializer
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()


class WeighingAPIListCreate(generics.ListCreateAPIView):
"""
Lists or creates a new weighing.
"""
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()
serializer_class = WeighingDetailSerializer
queryset = Weighing.objects.all()
queryset = WeighingDetailSerializer.setup_eager_loading(queryset)
Expand All @@ -369,23 +370,23 @@ class WeighingAPIDetail(generics.RetrieveDestroyAPIView):
"""
Allows viewing of full detail and deleting a weighing.
"""
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()
serializer_class = WeighingDetailSerializer
queryset = Weighing.objects.all()


class WaterTypeList(generics.ListCreateAPIView):
queryset = WaterType.objects.all()
serializer_class = WaterTypeDetailSerializer
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()
lookup_field = 'name'


class WaterAdministrationAPIListCreate(generics.ListCreateAPIView):
"""
Lists or creates a new water administration.
"""
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()
serializer_class = WaterAdministrationDetailSerializer
queryset = WaterAdministration.objects.all()
queryset = WaterAdministrationDetailSerializer.setup_eager_loading(queryset)
Expand All @@ -396,7 +397,7 @@ class WaterAdministrationAPIDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Allows viewing of full detail and deleting a water administration.
"""
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()
serializer_class = WaterAdministrationDetailSerializer
queryset = WaterAdministration.objects.all()

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


class WaterRequirement(APIView):
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()

def get(self, request, format=None, nickname=None):
assert nickname
Expand All @@ -439,7 +440,7 @@ class WaterRestrictionList(generics.ListAPIView):
"""
queryset = WaterRestriction.objects.all().order_by('-end_time', '-start_time')
serializer_class = WaterRestrictionListSerializer
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()
filter_class = WaterRestrictionFilter


Expand All @@ -449,14 +450,14 @@ class LabLocationList(generics.ListAPIView):
"""
queryset = LabLocation.objects.all()
serializer_class = LabLocationSerializer
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()


class LabLocationAPIDetails(generics.RetrieveUpdateAPIView):
"""
Allows viewing of full detail and deleting a water administration.
"""
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()
serializer_class = LabLocationSerializer
queryset = LabLocation.objects.all()
lookup_field = 'name'
Expand All @@ -468,4 +469,4 @@ class SurgeriesList(generics.ListAPIView):
"""
queryset = Surgery.objects.all()
serializer_class = SurgerySerializer
permission_classes = (permissions.IsAuthenticated,)
permission_classes = rest_permission_classes()
2 changes: 2 additions & 0 deletions alyx/alyx/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__version__ = '1.0.0'
VERSION = __version__ # synonym
42 changes: 38 additions & 4 deletions alyx/alyx/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@
from django_filters.rest_framework import FilterSet
from rest_framework.views import exception_handler

from rest_framework import permissions
from dateutil.parser import parse
from reversion.admin import VersionAdmin

from alyx import __version__ as version

logger = structlog.get_logger(__name__)

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

def has_add_permission(self, request, *args, **kwargs):
if request.user.is_public_user:
return False
else:
return super(BaseAdmin, self).has_add_permission(request, *args, **kwargs)

def has_change_permission(self, request, obj=None):
if request.user.is_public_user:
return False
if not obj:
return True
if request.user.is_superuser:
return True
# Subject associated to the object.
subj = obj if hasattr(obj, 'responsible_user') else getattr(obj, 'subject', None)
# Find subject associated to the object.
if hasattr(obj, 'responsible_user'):
subj = obj
elif getattr(obj, 'session', None):
subj = obj.session.subject
elif getattr(obj, 'subject', None):
subj = obj.subject
else:
return False
resp_user = getattr(subj, 'responsible_user', None)
# List of allowed users for the subject.
allowed = getattr(resp_user, 'allowed_users', None)
Expand Down Expand Up @@ -562,11 +578,29 @@ def rest_filters_exception_handler(exc, context):
return response


class BaseRestPublicPermission(permissions.BasePermission):
"""
The purpose is to prevent public users from interfering in any way using writable methods
"""
def has_permission(self, request, view):
if request.method == 'GET':
return True
elif request.user.is_public_user:
return False
else:
return True


def rest_permission_classes():
permission_classes = (permissions.IsAuthenticated & BaseRestPublicPermission,)
return permission_classes


mysite = MyAdminSite()
mysite.site_header = 'Alyx'
mysite.site_title = 'Alyx'
mysite.site_url = None
mysite.index_title = 'Welcome to Alyx'
mysite.index_title = f'Welcome to Alyx {version}'
mysite.enable_nav_sidebar = False

admin.site = mysite
1 change: 1 addition & 0 deletions alyx/alyx/settings_lab_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
STOCK_MANAGERS = ('root',)
WEIGHT_THRESHOLD = 0.75
DEFAULT_LAB_NAME = 'defaultlab'
WATER_RESTRICTIONS_EDITABLE = False # if set to True, all users can edit water restrictions
DEFAULT_LAB_PK = '4027da48-7be3-43ec-a222-f75dffe36872'
SESSION_REPO_URL = \
"http://ibl.flatironinstitute.org/{lab}/Subjects/{subject}/{date}/{number:03d}/"
Expand Down
Loading

0 comments on commit 48701ad

Please sign in to comment.