diff --git a/.gitignore b/.gitignore index 6a9fa1cf6..f3260b541 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ alyx/alyx/settings_lab.py alyx/alyx/settings.py alyx/.idea/ + +alyx.log diff --git a/README.md b/README.md index cd97c4939..a2a6b581d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/alyx/actions/admin.py b/alyx/actions/admin.py index e026465ff..53643cf51 100644 --- a/alyx/actions/admin.py +++ b/alyx/actions/admin.py @@ -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')] @@ -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 diff --git a/alyx/actions/migrations/0017_alter_chronicrecording_subject_and_more.py b/alyx/actions/migrations/0017_alter_chronicrecording_subject_and_more.py new file mode 100644 index 000000000..ab871eceb --- /dev/null +++ b/alyx/actions/migrations/0017_alter_chronicrecording_subject_and_more.py @@ -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'), + ), + ] diff --git a/alyx/actions/models.py b/alyx/actions/models.py index 12ef2c7e6..77f54cd9b 100644 --- a/alyx/actions/models.py +++ b/alyx/actions/models.py @@ -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): @@ -314,6 +318,13 @@ 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) @@ -321,7 +332,14 @@ 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)) - 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): @@ -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): diff --git a/alyx/actions/views.py b/alyx/actions/views.py index 0e80b8aad..1094793d6 100644 --- a/alyx/actions/views.py +++ b/alyx/actions/views.py @@ -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 @@ -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): @@ -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) @@ -369,7 +370,7 @@ 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() @@ -377,7 +378,7 @@ class WeighingAPIDetail(generics.RetrieveDestroyAPIView): class WaterTypeList(generics.ListCreateAPIView): queryset = WaterType.objects.all() serializer_class = WaterTypeDetailSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' @@ -385,7 +386,7 @@ 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) @@ -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() @@ -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 @@ -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 @@ -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' @@ -468,4 +469,4 @@ class SurgeriesList(generics.ListAPIView): """ queryset = Surgery.objects.all() serializer_class = SurgerySerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() diff --git a/alyx/alyx/__init__.py b/alyx/alyx/__init__.py index e69de29bb..ef3f584d9 100644 --- a/alyx/alyx/__init__.py +++ b/alyx/alyx/__init__.py @@ -0,0 +1,2 @@ +__version__ = '1.0.0' +VERSION = __version__ # synonym diff --git a/alyx/alyx/base.py b/alyx/alyx/base.py index 2ffca03b6..d28f9376d 100644 --- a/alyx/alyx/base.py +++ b/alyx/alyx/base.py @@ -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__) @@ -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) @@ -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 diff --git a/alyx/alyx/settings_lab_template.py b/alyx/alyx/settings_lab_template.py index 9236fa90a..655d4a9d1 100644 --- a/alyx/alyx/settings_lab_template.py +++ b/alyx/alyx/settings_lab_template.py @@ -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}/" diff --git a/alyx/alyx/settings_template.py b/alyx/alyx/settings_template.py index 06e073712..e6bd8c623 100644 --- a/alyx/alyx/settings_template.py +++ b/alyx/alyx/settings_template.py @@ -9,6 +9,7 @@ """ import os + import structlog from django.conf.locale.en import formats as en_formats @@ -29,7 +30,6 @@ en_formats.DATETIME_FORMAT = "d/m/Y H:i" DATE_INPUT_FORMATS = ('%d/%m/%Y',) - if 'GITHUB_ACTIONS' in os.environ: DATABASES = { 'default': { @@ -42,11 +42,9 @@ } } - # Custom User model with UUID primary key AUTH_USER_MODEL = 'misc.LabMember' - BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000 @@ -106,10 +104,13 @@ 'django_structlog': { 'handlers': ['json_file'], 'level': 'INFO', - } + }, }, 'root': { - 'handlers': ['file', 'console'], + 'handlers': [ + 'file', + 'console', + ], 'level': 'WARNING', 'propagate': True, } diff --git a/alyx/data/migrations/0010_alter_dataset_created_by_and_more.py b/alyx/data/migrations/0010_alter_dataset_created_by_and_more.py new file mode 100644 index 000000000..f540cf8a6 --- /dev/null +++ b/alyx/data/migrations/0010_alter_dataset_created_by_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.0.3 on 2022-03-30 15:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('actions', '0017_alter_chronicrecording_subject_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('data', '0009_auto_20210624_1253'), + ] + + operations = [ + migrations.AlterField( + model_name='dataset', + name='created_by', + field=models.ForeignKey(blank=True, help_text='The creator of the data.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_created_by_related', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='dataset', + name='provenance_directory', + field=models.ForeignKey(blank=True, help_text='link to directory containing intermediate results', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_provenance_related', to='data.dataset'), + ), + migrations.AlterField( + model_name='dataset', + name='session', + field=models.ForeignKey(blank=True, help_text='The Session to which this data belongs', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_session_related', to='actions.session'), + ), + migrations.AlterField( + model_name='datasettype', + name='created_by', + field=models.ForeignKey(blank=True, help_text='The creator of the data.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_created_by_related', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/alyx/data/transfers.py b/alyx/data/transfers.py index 09e2bf07b..1aab0b619 100644 --- a/alyx/data/transfers.py +++ b/alyx/data/transfers.py @@ -380,7 +380,7 @@ def transfers_required(dataset): } -def bulk_sync(dry_run=False, lab=None, gc=None): +def bulk_sync(dry_run=False, lab=None, gc=None, check_mismatch=False): """ updates the Alyx database file records field 'exists' by looking at each Globus repository. This is meant to be launched before the transfer() function @@ -394,12 +394,19 @@ def bulk_sync(dry_run=False, lab=None, gc=None): :param lab (optional) specific lab name only :param gc (optional) globus transfer client. If not given will instantiated within fucntion :param local_only: (False) if set to True, only local files will be checked. This is useful + :param check_mismatch: (False) if set to True, will add to the queries filerecords existing + on SDSC but labeled as mismatched hash for patching files """ - dfs = FileRecord.objects.filter( - Q(exists=False, data_repository__globus_is_personal=False, - data_repository__name__icontains='flatiron') | - Q(json__has_key="mismatch_hash")) + if check_mismatch: + dfs = FileRecord.objects.filter( + Q(exists=False, data_repository__globus_is_personal=False, + data_repository__name__icontains='flatiron') | + Q(json__has_key="mismatch_hash")) + else: + dfs = FileRecord.objects.filter( + Q(exists=False, data_repository__globus_is_personal=False, + data_repository__name__icontains='flatiron')) if lab: dfs = dfs.filter(data_repository__lab__name=lab) # get all the datasets concerned and then back down to get all files for all those datasets diff --git a/alyx/data/views.py b/alyx/data/views.py index 35e65ef14..0d22b74d9 100644 --- a/alyx/data/views.py +++ b/alyx/data/views.py @@ -3,11 +3,11 @@ from pathlib import Path from django.contrib.auth import get_user_model -from rest_framework import generics, permissions, viewsets, mixins, serializers +from rest_framework import generics, viewsets, mixins, serializers from rest_framework.response import Response import django_filters -from alyx.base import BaseFilterSet +from alyx.base import BaseFilterSet, rest_permission_classes from subjects.models import Subject, Project from experiments.models import ProbeInsertion from misc.models import Lab @@ -44,14 +44,14 @@ class DataRepositoryTypeList(generics.ListCreateAPIView): queryset = DataRepositoryType.objects.all() serializer_class = DataRepositoryTypeSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' class DataRepositoryTypeDetail(generics.RetrieveUpdateDestroyAPIView): queryset = DataRepositoryType.objects.all() serializer_class = DataRepositoryTypeSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' @@ -61,7 +61,7 @@ class DataRepositoryTypeDetail(generics.RetrieveUpdateDestroyAPIView): class DataRepositoryList(generics.ListCreateAPIView): queryset = DataRepository.objects.all() serializer_class = DataRepositorySerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_fields = ('name', 'globus_is_personal', 'globus_endpoint_id') lookup_field = 'name' @@ -69,7 +69,7 @@ class DataRepositoryList(generics.ListCreateAPIView): class DataRepositoryDetail(generics.RetrieveUpdateDestroyAPIView): queryset = DataRepository.objects.all() serializer_class = DataRepositorySerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' @@ -79,14 +79,14 @@ class DataRepositoryDetail(generics.RetrieveUpdateDestroyAPIView): class DataFormatList(generics.ListCreateAPIView): queryset = DataFormat.objects.all() serializer_class = DataFormatSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' class DataFormatDetail(generics.RetrieveUpdateDestroyAPIView): queryset = DataFormat.objects.all() serializer_class = DataFormatSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' @@ -96,14 +96,14 @@ class DataFormatDetail(generics.RetrieveUpdateDestroyAPIView): class DatasetTypeList(generics.ListCreateAPIView): queryset = DatasetType.objects.all() serializer_class = DatasetTypeSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' class DatasetTypeDetail(generics.RetrieveUpdateDestroyAPIView): queryset = DatasetType.objects.all() serializer_class = DatasetTypeSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' @@ -113,26 +113,26 @@ class DatasetTypeDetail(generics.RetrieveUpdateDestroyAPIView): class RevisionList(generics.ListCreateAPIView): queryset = Revision.objects.all() serializer_class = RevisionSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() class RevisionDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Revision.objects.all() serializer_class = RevisionSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' class TagList(generics.ListCreateAPIView): queryset = Tag.objects.all() serializer_class = TagSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() class TagDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Tag.objects.all() serializer_class = TagSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() # Dataset # ------------------------------------------------------------------------------------------------ @@ -217,14 +217,14 @@ class DatasetList(generics.ListCreateAPIView): queryset = Dataset.objects.all() queryset = DatasetSerializer.setup_eager_loading(queryset) serializer_class = DatasetSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_class = DatasetFilter class DatasetDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Dataset.objects.all() serializer_class = DatasetSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() # FileRecord @@ -254,14 +254,14 @@ class FileRecordList(generics.ListCreateAPIView): queryset = FileRecord.objects.all() queryset = FileRecordSerializer.setup_eager_loading(queryset) serializer_class = FileRecordSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_class = FileRecordFilter class FileRecordDetail(generics.RetrieveUpdateDestroyAPIView): queryset = FileRecord.objects.all() serializer_class = FileRecordSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() # Register file @@ -534,7 +534,7 @@ class DownloadDetail(generics.RetrieveUpdateAPIView): """ queryset = Download.objects.all() serializer_class = DownloadSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() class DownloadFilter(BaseFilterSet): @@ -561,5 +561,5 @@ class DownloadList(generics.ListAPIView): """ queryset = Download.objects.all() serializer_class = DownloadSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_class = DownloadFilter diff --git a/alyx/experiments/admin.py b/alyx/experiments/admin.py index 59a6ac892..8ab3feca3 100644 --- a/alyx/experiments/admin.py +++ b/alyx/experiments/admin.py @@ -3,7 +3,6 @@ from django.utils.safestring import SafeString from django.utils.html import format_html from django.contrib.admin import TabularInline -from reversion.admin import VersionAdmin from mptt.admin import MPTTModelAdmin @@ -59,7 +58,7 @@ class ChannelAdmin(BaseAdmin): readonly_fields = ['trajectory_estimate', 'brain_region'] -class TrajectoryEstimateAdmin(VersionAdmin): +class TrajectoryEstimateAdmin(BaseAdmin): exclude = ['probe_insertion'] readonly_fields = ['datetime', '_probe_insertion', 'session', '_channel_count'] list_display = ['datetime', 'subject', '_probe_insertion', 'provenance', '_channel_count', diff --git a/alyx/experiments/views.py b/alyx/experiments/views.py index 75a5867a4..762849ef7 100644 --- a/alyx/experiments/views.py +++ b/alyx/experiments/views.py @@ -1,9 +1,9 @@ -from rest_framework import generics, permissions +from rest_framework import generics from django_filters.rest_framework import CharFilter, UUIDFilter, NumberFilter from django.db.models import F, Func, Value, CharField, functions, Q -from alyx.base import BaseFilterSet +from alyx.base import BaseFilterSet, rest_permission_classes from data.models import Dataset from experiments.models import ProbeInsertion, TrajectoryEstimate, Channel, BrainRegion from experiments.serializers import (ProbeInsertionListSerializer, ProbeInsertionDetailSerializer, @@ -127,14 +127,14 @@ class ProbeInsertionList(generics.ListCreateAPIView): queryset = ProbeInsertion.objects.all() queryset = ProbeInsertionListSerializer.setup_eager_loading(queryset) serializer_class = ProbeInsertionListSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_class = ProbeInsertionFilter class ProbeInsertionDetail(generics.RetrieveUpdateDestroyAPIView): queryset = ProbeInsertion.objects.all() serializer_class = ProbeInsertionDetailSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() """ @@ -174,14 +174,14 @@ class TrajectoryEstimateList(generics.ListCreateAPIView): """ queryset = TrajectoryEstimate.objects.all() serializer_class = TrajectoryEstimateSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_class = TrajectoryEstimateFilter class TrajectoryEstimateDetail(generics.RetrieveUpdateDestroyAPIView): queryset = TrajectoryEstimate.objects.all() serializer_class = TrajectoryEstimateSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() class ChannelFilter(BaseFilterSet): @@ -215,14 +215,14 @@ def get_serializer(self, *args, **kwargs): queryset = Channel.objects.all() serializer_class = ChannelSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_class = ChannelFilter class ChannelDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Channel.objects.all() serializer_class = ChannelSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() class BrainRegionFilter(BaseFilterSet): @@ -261,11 +261,11 @@ class BrainRegionList(generics.ListAPIView): """ queryset = BrainRegion.objects.all() serializer_class = BrainRegionSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_class = BrainRegionFilter class BrainRegionDetail(generics.RetrieveUpdateAPIView): queryset = BrainRegion.objects.all() serializer_class = BrainRegionSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() diff --git a/alyx/jobs/views.py b/alyx/jobs/views.py index f5033c28a..d4e30dd24 100644 --- a/alyx/jobs/views.py +++ b/alyx/jobs/views.py @@ -1,11 +1,11 @@ from django.db.models import Q, Count, Max -from rest_framework import generics, permissions +from rest_framework import generics from django_filters.rest_framework import CharFilter from django.views.generic.list import ListView import numpy as np -from alyx.base import BaseFilterSet +from alyx.base import BaseFilterSet, rest_permission_classes import django_filters from misc.models import Lab @@ -99,11 +99,11 @@ class TaskList(generics.ListCreateAPIView): """ queryset = Task.objects.all().order_by('level', '-priority', '-session__start_time') serializer_class = TaskSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_class = TaskFilter class TaskDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Task.objects.all() serializer_class = TaskSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() diff --git a/alyx/misc/migrations/0010_labmember_is_public_user.py b/alyx/misc/migrations/0010_labmember_is_public_user.py new file mode 100644 index 000000000..d2d710575 --- /dev/null +++ b/alyx/misc/migrations/0010_labmember_is_public_user.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2022-03-30 15:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('misc', '0009_auto_20211122_1535'), + ] + + operations = [ + migrations.AddField( + model_name='labmember', + name='is_public_user', + field=models.BooleanField(default=False), + ), + ] diff --git a/alyx/misc/models.py b/alyx/misc/models.py index 6d6db80f2..7c43ce727 100644 --- a/alyx/misc/models.py +++ b/alyx/misc/models.py @@ -27,6 +27,7 @@ class LabMember(AbstractUser): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) is_stock_manager = models.BooleanField(default=False) allowed_users = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True) + is_public_user = models.BooleanField(default=False) class Meta: ordering = ['username'] diff --git a/alyx/misc/tests_rest.py b/alyx/misc/tests_rest.py index e1287d2b9..6eb87ddcc 100644 --- a/alyx/misc/tests_rest.py +++ b/alyx/misc/tests_rest.py @@ -12,6 +12,8 @@ def setUp(self): self.superuser = get_user_model().objects.create_superuser('test', 'test', 'test') self.client.login(username='test', password='test') self.lab = Lab.objects.create(name='basement') + self.public_user = get_user_model().objects.create( + username='troublemaker', password='azerty', is_public_user=True) def test_create_lab_membership(self): # first test creation of lab through rest endpoint @@ -37,6 +39,13 @@ def test_create_lab_membership(self): d = self.ar(response, 200) self.assertTrue(set(d['lab']) == set(self.superuser.lab)) + def test_public_user(self): + # makes sure the public user can't post + self.client.login(username='troublemaker', password='azerty') + self.client.force_login(user=self.public_user) + response = self.post(reverse('lab-list'), {'name': 'prank'}) + self.ar(response, 403) + def test_user_rest(self): response = self.client.get(reverse('user-list') + '/test') self.ar(response, 200) diff --git a/alyx/misc/views.py b/alyx/misc/views.py index 7d293ea0c..5964c8fbb 100644 --- a/alyx/misc/views.py +++ b/alyx/misc/views.py @@ -10,9 +10,9 @@ from rest_framework.response import Response from rest_framework.decorators import api_view from rest_framework.reverse import reverse -from rest_framework import generics, permissions +from rest_framework import generics -from alyx.base import BaseFilterSet +from alyx.base import BaseFilterSet, rest_permission_classes from .serializers import UserSerializer, LabSerializer, NoteSerializer from .models import Lab, Note from alyx.settings import TABLES_ROOT, MEDIA_ROOT @@ -72,7 +72,7 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): queryset = UserSerializer.setup_eager_loading(queryset) serializer_class = UserSerializer lookup_field = 'username' - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() class LabFilter(BaseFilterSet): @@ -86,7 +86,7 @@ class Meta: class LabList(generics.ListCreateAPIView): queryset = Lab.objects.all() serializer_class = LabSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' filter_class = LabFilter @@ -94,7 +94,7 @@ class LabList(generics.ListCreateAPIView): class LabDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Lab.objects.all() serializer_class = LabSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' @@ -112,18 +112,18 @@ class NoteList(generics.ListCreateAPIView): """ queryset = Note.objects.all() serializer_class = NoteSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_class = BaseFilterSet class NoteDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Note.objects.all() serializer_class = NoteSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() class UploadedView(views.APIView): - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() def get(self, request=None, format=None, img_url=''): path = op.join(MEDIA_ROOT, img_url) @@ -141,14 +141,14 @@ def _get_cache_info(): class CacheVersionView(views.APIView): - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() def get(self, request=None, **kwargs): return JsonResponse(_get_cache_info()) class CacheDownloadView(views.APIView): - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() def get(self, request=None, **kwargs): cache_file = Path(TABLES_ROOT).joinpath('cache.zip') diff --git a/alyx/subjects/admin.py b/alyx/subjects/admin.py index f8fb6eeb9..5498d546f 100755 --- a/alyx/subjects/admin.py +++ b/alyx/subjects/admin.py @@ -781,21 +781,26 @@ def __init__(self, *args, **kwargs): super(BreedingPairAdminForm, self).__init__(*args, **kwargs) for w in ('father', 'mother1', 'mother2'): sex = 'M' if w == 'father' else 'F' - p = getattr(self.instance, w, None) - if p and p.cage: - self.fields['cage'].initial = p.cage + # Remove this feature as requested by Charu (03/2022) + # p = getattr(self.instance, w, None) + # if p and p.cage: + # self.fields['cage'].initial = p.cage + if w in self.fields: self.fields[w].queryset = _bp_subjects(self.instance.line, sex) - def save(self, commit=True): - cage = self.cleaned_data.get('cage') - if cage: - for w in ('father', 'mother1', 'mother2'): - p = getattr(self.instance, w, None) - if p: - p.cage = int(cage) - p.save() - return super(BreedingPairAdminForm, self).save(commit=commit) + # def save(self, commit=True): + # cage = self.cleaned_data.get('cage') + # if cage: + # for w in ('father', 'mother1', 'mother2'): + # p = getattr(self.instance, w, None) + # if p: + + # # Bug fix (request by Charu in 03/2022): allow cage ids to be non integers + # # p.cage = int(cage) + + # p.save() + # return super(BreedingPairAdminForm, self).save(commit=commit) class Meta: fields = '__all__' @@ -1287,7 +1292,8 @@ class LabMemberAdmin(UserAdmin): form = LabMemberAdminForm fieldsets = UserAdmin.fieldsets + ( - ('Extra fields', {'fields': ('allowed_users',)}), + ('Extra fields', {'fields': ('allowed_users',)},), + ('Permissions', {'fields': ('is_stock_manager', 'is_public_user')}) ) add_fieldsets = UserAdmin.add_fieldsets + ( ('Extra fields', {'fields': ('allowed_users',)}), @@ -1297,8 +1303,9 @@ class LabMemberAdmin(UserAdmin): list_display = ['username', 'email', 'first_name', 'last_name', 'groups_l', 'allowed_users_', 'is_staff', 'is_superuser', 'is_stock_manager', + 'is_public_user' ] - list_editable = ['is_stock_manager'] + list_editable = ['is_stock_manager', 'is_public_user'] save_on_top = True def get_form(self, request, obj=None, **kwargs): diff --git a/alyx/subjects/models.py b/alyx/subjects/models.py index 4019bdc7f..49ceaa462 100644 --- a/alyx/subjects/models.py +++ b/alyx/subjects/models.py @@ -15,6 +15,7 @@ from alyx.base import BaseModel, alyx_mail, modify_fields from actions.notifications import responsible_user_changed from actions.water_control import water_control +from actions.models import Surgery from misc.models import Lab, default_lab, Housing logger = structlog.get_logger(__name__) @@ -335,9 +336,12 @@ def save(self, *args, **kwargs): if self.line and not self.strain: self.strain = self.line.strain # Update the zygosities when the subject is created or assigned a litter. - is_created = self._state.adding is True - if is_created or (self.litter_id and not _get_old_field(self, 'litter')): - ZygosityFinder().genotype_from_litter(self) + # Remove the automatic zygosity creation when assigning a litter, as requested by Charu + # in 03/2022. + # is_created = self._state.adding is True + # if is_created or (self.litter_id and not _get_old_field(self, 'litter')): + # ZygosityFinder().genotype_from_litter(self) + # Remove "to be genotyped" if genotype date is set. if self.genotype_date and not _get_old_field(self, 'genotype_date'): self.to_be_genotyped = False @@ -377,6 +381,14 @@ def save(self, *args, **kwargs): save_old_fields(self, self._fields_history) return super(Subject, self).save(*args, **kwargs) + def set_protocol_number(self): + if self.water_control.is_water_restricted(): + self.protocol_number = '3' + elif Surgery.objects.filter(subject=self).count() > 0: + self.protocol_number = '2' + else: + self.protocol_number = '1' + def __str__(self): return self.nickname @@ -719,6 +731,7 @@ def _update_zygosities(line, sequence): class ZygosityRule(BaseModel): + """This model encodes a rule to automatically create a zygosity from genotyping results.""" line = models.ForeignKey('Line', null=True, on_delete=models.SET_NULL) allele = models.ForeignKey('Allele', null=True, on_delete=models.SET_NULL) sequence0 = models.ForeignKey('Sequence', blank=True, null=True, on_delete=models.SET_NULL, diff --git a/alyx/subjects/tests.py b/alyx/subjects/tests.py index 6447d3448..c8d696278 100644 --- a/alyx/subjects/tests.py +++ b/alyx/subjects/tests.py @@ -172,7 +172,7 @@ def test_zygosities_2(self): subject = m.Subject.objects.create( nickname='subject', line=line, litter=litter, lab=self.lab) z = m.Zygosity.objects.filter(subject=subject).first() - assert z.zygosity == 2 # from parents + assert z is None # no zygosity should be assigned from parents # Create a rule and a genotype test ; the subject should be automatically genotyped. zr = m.ZygosityRule.objects.create( @@ -244,6 +244,30 @@ def test_zygosities_3(self): assert a.zygosity == 2 +class SubjectProtocolNumber(TestCase): + + def setUp(self): + self.lab = Lab.objects.create(name='awesomelab') + self.sub = Subject.objects.create(nickname='lawes', lab=self.lab, birth_date='2019-01-01') + + def test_protocol_number(self): + from actions.models import Surgery + 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)) + assert self.sub.protocol_number == '2' + # after water restriction number goes to 3 + self.wr = WaterRestriction.objects.create( + subject=self.sub, start_time=datetime(2019, 1, 1, 12, 0, 0)) + assert self.sub.protocol_number == '3' + self.wr.end_time = datetime(2019, 1, 2, 12, 0, 0) + self.wr.save() + assert self.sub.protocol_number == '2' + self.surgery.delete() + assert self.sub.protocol_number == '1' + + class SubjectCullTests(TestCase): def setUp(self): @@ -260,6 +284,8 @@ def test_update_cull_object(self): self.assertFalse(hasattr(self.sub1, 'cull')) # self.assertIsNone(self.wr.end_time) # makes sure than when creating the cull + # if there is an integrity error here, it means the save functions are saving the cull + # several time and the water restriction/ cull / subjects save are interdependent cull = Cull.objects.create(subject=self.sub1, date='2019-07-15', cull_method=self.CO2) self.assertEqual(self.sub1.death_date, cull.date) # change cull properties and make sure the corresponding subject properties changed too diff --git a/alyx/subjects/views.py b/alyx/subjects/views.py index feb9aa86d..42353c4f9 100644 --- a/alyx/subjects/views.py +++ b/alyx/subjects/views.py @@ -1,7 +1,7 @@ -from rest_framework import generics, permissions +from rest_framework import generics import django_filters -from alyx.base import BaseFilterSet +from alyx.base import BaseFilterSet, rest_permission_classes from .models import Subject, Project from .serializers import (SubjectListSerializer, SubjectDetailSerializer, @@ -48,28 +48,28 @@ class SubjectList(generics.ListCreateAPIView): queryset = Subject.objects.all() queryset = SubjectListSerializer.setup_eager_loading(queryset) serializer_class = SubjectListSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() filter_class = SubjectFilter class SubjectDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Subject.objects.all() serializer_class = SubjectDetailSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'nickname' class ProjectList(generics.ListCreateAPIView): queryset = Project.objects.all() serializer_class = ProjectSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' class ProjectDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Project.objects.all() serializer_class = ProjectSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() lookup_field = 'name' @@ -79,4 +79,4 @@ class WaterRestrictedSubjectList(generics.ListAPIView): (SELECT subject_id FROM actions_waterrestriction WHERE end_time IS NULL)''']) serializer_class = WaterRestrictedSubjectListSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() diff --git a/requirements.txt b/requirements.txt index 519d69026..e966d443b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,4 @@ python-magic pytz webdavclient3 django-structlog +structlog~=21.5.0 \ No newline at end of file