From 718d9daefa161368ee9fa37e74e8fd4ef4dfbcd2 Mon Sep 17 00:00:00 2001 From: Michele Fabbri Date: Sun, 6 Feb 2022 13:22:32 +0000 Subject: [PATCH 01/28] cloudwatch logging with watchtower --- alyx/alyx/settings_template.py | 24 +++++++++++++++++++----- requirements.txt | 2 ++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/alyx/alyx/settings_template.py b/alyx/alyx/settings_template.py index 06e07371..faed959c 100644 --- a/alyx/alyx/settings_template.py +++ b/alyx/alyx/settings_template.py @@ -9,6 +9,8 @@ """ import os + +import boto3 import structlog from django.conf.locale.en import formats as en_formats @@ -29,7 +31,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,16 +43,23 @@ } } - # 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 DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +# Configuration for watchtower cloudwatch log export, env variables set in /etc/apache2/envvars +# Ideally, this will be done with IAM Roles in the future +boto3_logs_client = boto3.client( + "logs", + region_name=os.environ.get('CLOUDWATCH_DJANGO_DEV_REGION'), + aws_access_key_id=os.environ.get('CLOUDWATCH_DJANGO_DEV_ID'), + aws_secret_access_key=os.environ.get('CLOUDWATCH_DJANGO_DEV_KEY') +) + LOGGING = { 'version': 1, 'disable_existing_loggers': False, @@ -96,6 +104,12 @@ 'backupCount': 5, 'formatter': 'json_formatter', }, + 'watchtower': { + 'level': 'INFO', + 'class': 'watchtower.CloudWatchLogHandler', + 'boto3_client': boto3_logs_client, + 'log_group_name': 'django_dev', + }, }, 'loggers': { 'django': { @@ -106,10 +120,10 @@ 'django_structlog': { 'handlers': ['json_file'], 'level': 'INFO', - } + }, }, 'root': { - 'handlers': ['file', 'console'], + 'handlers': ['file', 'console', 'watchtower'], 'level': 'WARNING', 'propagate': True, } diff --git a/requirements.txt b/requirements.txt index 519d6902..2e4fddb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,5 @@ python-magic pytz webdavclient3 django-structlog +structlog~=21.5.0 +boto3~=1.20.49 \ No newline at end of file From 7c51df6360f4c29aa1cf957143d8a0b7b6361ad6 Mon Sep 17 00:00:00 2001 From: Michele Fabbri Date: Tue, 15 Feb 2022 08:35:38 +0000 Subject: [PATCH 02/28] notes added for production release --- README.md | 10 +++++++++- alyx/alyx/settings_template.py | 8 +++++++- requirements.txt | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cd97c493..22b13f10 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,14 @@ Location of error logs for apache if it fails to start /var/log/apache2/ +### [Optional] Setup AWS Cloudwatch logging + +If you are running the alyx instance as an EC2 instance on AWS, perform the following steps to export the django logs to a Cloudwatch log stream. + +https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html + +arn:aws:iam::aws:policy/AWSOpsWorksCloudWatchLogs + --- ### [macOS] Local installation of alyx diff --git a/alyx/alyx/settings_template.py b/alyx/alyx/settings_template.py index faed959c..00b70407 100644 --- a/alyx/alyx/settings_template.py +++ b/alyx/alyx/settings_template.py @@ -10,7 +10,7 @@ import os -import boto3 +import boto3 # Optional, only required if AWS Cloudwatch logging is desired import structlog from django.conf.locale.en import formats as en_formats @@ -51,6 +51,7 @@ DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000 DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +# Optional, only required if AWS Cloudwatch logging is desired # Configuration for watchtower cloudwatch log export, env variables set in /etc/apache2/envvars # Ideally, this will be done with IAM Roles in the future boto3_logs_client = boto3.client( @@ -59,6 +60,9 @@ aws_access_key_id=os.environ.get('CLOUDWATCH_DJANGO_DEV_ID'), aws_secret_access_key=os.environ.get('CLOUDWATCH_DJANGO_DEV_KEY') ) +# Ideal configuration using IAM permissions and not env vars +# AWS_REGION_NAME = 'eu-west-2' +# boto3_logs_client = boto3.client("logs", region_name=AWS_REGION_NAME) LOGGING = { 'version': 1, @@ -104,6 +108,7 @@ 'backupCount': 5, 'formatter': 'json_formatter', }, + # Optional, only required if AWS Cloudwatch logging is desired 'watchtower': { 'level': 'INFO', 'class': 'watchtower.CloudWatchLogHandler', @@ -123,6 +128,7 @@ }, }, 'root': { + # Optional, watchtower entry only required if AWS Cloudwatch logging is desired 'handlers': ['file', 'console', 'watchtower'], 'level': 'WARNING', 'propagate': True, diff --git a/requirements.txt b/requirements.txt index 2e4fddb4..88017ea5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,4 +26,4 @@ pytz webdavclient3 django-structlog structlog~=21.5.0 -boto3~=1.20.49 \ No newline at end of file +boto3~=1.20.49 # Optional, only required if AWS Cloudwatch logging is desired \ No newline at end of file From 887a23b2ba50a2f822cdb8fec1aa21d29a83251c Mon Sep 17 00:00:00 2001 From: Michele Fabbri Date: Tue, 15 Feb 2022 11:24:18 +0000 Subject: [PATCH 03/28] fleshed out optional cloudwatch instructions and configurations --- README.md | 48 +++++++++++++++++++++++++++++----- alyx/alyx/settings_template.py | 32 +++++++++-------------- requirements.txt | 3 ++- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 22b13f10..7ca35dc4 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,47 @@ Location of error logs for apache if it fails to start /var/log/apache2/ -### [Optional] Setup AWS Cloudwatch logging - -If you are running the alyx instance as an EC2 instance on AWS, perform the following steps to export the django logs to a Cloudwatch log stream. - -https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html - -arn:aws:iam::aws:policy/AWSOpsWorksCloudWatchLogs +### [Optional] Setup AWS Cloudwatch logging with watchtower + +If you are running the alyx as an EC2 instance on AWS, perform the following steps to export the django logs to a +Cloudwatch log stream. Please be sure that you are in the same virtualenv as before. + +- Navigate to your AWS console and open the 'Identity and Access Management (IAM)' page +- Create an IAM Role named something along the lines of 'Alyx-Prod' and attach the following policy: +`arn:aws:iam::aws:policy/AWSOpsWorksCloudWatchLogs` +- Navigate to your EC2 instance and with the instance selected choose: Actions -> Security -> Modify IAM Role +- Attach the newly created IAM policy to your EC2 instance (instance may require reboot for metadata to populate for +boto3) +- For more thorough guides on how to create and manage your IAM Roles; working with EC2 metadata: + - [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) +- uncomment the `boto3` and `watchtower` lines in the `alyx/requirements.txt` file and rerun +`pip install -r requirements.txt` +- open your `alyx/alyx/settings.py` file, find and uncomment the following lines: + - `import boto3` + - `AWS_REGION_NAME = ` + - `boto3_logs_client = boto3.client("logs", region_name=AWS_REGION_NAME)` + - ``` + LOGGING = { + ... + 'handlers': { + ... + 'watchtower': { + 'level': 'INFO', + 'class': 'watchtower.CloudWatchLogHandler', + 'boto3_client': boto3_logs_client, + 'log_group_name': 'django_dev', + }, + }, + 'root': { + 'handlers': [ + ... + 'watchtower', + ... + ``` +- restart apache gracefully with a `apachectl -k graceful` command +- navigate to your alyx instance web page, refresh the page a few times to generate logs and take note of the time +- after a few minutes, you can verify that the newly generated logs are being written in your AWS Cloudwatch console --- diff --git a/alyx/alyx/settings_template.py b/alyx/alyx/settings_template.py index 00b70407..0372718f 100644 --- a/alyx/alyx/settings_template.py +++ b/alyx/alyx/settings_template.py @@ -10,7 +10,7 @@ import os -import boto3 # Optional, only required if AWS Cloudwatch logging is desired +# import boto3 # Optional, only required if AWS Cloudwatch logging is desired import structlog from django.conf.locale.en import formats as en_formats @@ -52,15 +52,6 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # Optional, only required if AWS Cloudwatch logging is desired -# Configuration for watchtower cloudwatch log export, env variables set in /etc/apache2/envvars -# Ideally, this will be done with IAM Roles in the future -boto3_logs_client = boto3.client( - "logs", - region_name=os.environ.get('CLOUDWATCH_DJANGO_DEV_REGION'), - aws_access_key_id=os.environ.get('CLOUDWATCH_DJANGO_DEV_ID'), - aws_secret_access_key=os.environ.get('CLOUDWATCH_DJANGO_DEV_KEY') -) -# Ideal configuration using IAM permissions and not env vars # AWS_REGION_NAME = 'eu-west-2' # boto3_logs_client = boto3.client("logs", region_name=AWS_REGION_NAME) @@ -108,13 +99,13 @@ 'backupCount': 5, 'formatter': 'json_formatter', }, - # Optional, only required if AWS Cloudwatch logging is desired - 'watchtower': { - 'level': 'INFO', - 'class': 'watchtower.CloudWatchLogHandler', - 'boto3_client': boto3_logs_client, - 'log_group_name': 'django_dev', - }, + # Optional, watchtower entry only required for AWS Cloudwatch logging + # 'watchtower': { + # 'level': 'INFO', + # 'class': 'watchtower.CloudWatchLogHandler', + # 'boto3_client': boto3_logs_client, + # 'log_group_name': 'django_dev', + # }, }, 'loggers': { 'django': { @@ -128,8 +119,11 @@ }, }, 'root': { - # Optional, watchtower entry only required if AWS Cloudwatch logging is desired - 'handlers': ['file', 'console', 'watchtower'], + 'handlers': [ + 'file', + 'console', + # 'watchtower', # Optional, watchtower entry only required for AWS Cloudwatch logging + ], 'level': 'WARNING', 'propagate': True, } diff --git a/requirements.txt b/requirements.txt index 88017ea5..2e682d8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,4 +26,5 @@ pytz webdavclient3 django-structlog structlog~=21.5.0 -boto3~=1.20.49 # Optional, only required if AWS Cloudwatch logging is desired \ No newline at end of file +# boto3~=1.20.49 # Optional, only required if AWS Cloudwatch logging is desired +# watchtower~=3.0.0 # Optional, only required if AWS Cloudwatch logging is desired \ No newline at end of file From e2833e7d8614546e4183e028f1d0cfc3727886e9 Mon Sep 17 00:00:00 2001 From: olivier Date: Thu, 3 Mar 2022 15:41:16 +0000 Subject: [PATCH 04/28] transfers: set check local mismatch as option --- alyx/data/transfers.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/alyx/data/transfers.py b/alyx/data/transfers.py index 09e2bf07..1aab0b61 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 From fdab834e935d48ee474aeb6a1a73926ec9fa42f4 Mon Sep 17 00:00:00 2001 From: olivier Date: Fri, 18 Mar 2022 14:44:32 +0000 Subject: [PATCH 05/28] change trajectories permissions for public Alyx --- alyx/alyx/base.py | 9 +++++++-- alyx/experiments/admin.py | 3 +-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/alyx/alyx/base.py b/alyx/alyx/base.py index 2ffca03b..eea4372f 100644 --- a/alyx/alyx/base.py +++ b/alyx/alyx/base.py @@ -321,8 +321,13 @@ def has_change_permission(self, request, obj=None): 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 resp_user = getattr(subj, 'responsible_user', None) # List of allowed users for the subject. allowed = getattr(resp_user, 'allowed_users', None) diff --git a/alyx/experiments/admin.py b/alyx/experiments/admin.py index 59a6ac89..8ab3feca 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', From 09ff14a497d38718ebe8d2801ebbb1abcf0ba4fe Mon Sep 17 00:00:00 2001 From: Michele Fabbri Date: Sat, 19 Mar 2022 10:37:48 +0000 Subject: [PATCH 06/28] tested with the latest boto3 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2e682d8b..4014e612 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,5 +26,5 @@ pytz webdavclient3 django-structlog structlog~=21.5.0 -# boto3~=1.20.49 # Optional, only required if AWS Cloudwatch logging is desired +# boto3~=1.21.22 # Optional, only required if AWS Cloudwatch logging is desired # watchtower~=3.0.0 # Optional, only required if AWS Cloudwatch logging is desired \ No newline at end of file From d9ebafe1e783bff0e6d38191105dc1093e387c10 Mon Sep 17 00:00:00 2001 From: Michele Fabbri Date: Mon, 21 Mar 2022 12:34:14 +0000 Subject: [PATCH 07/28] optional cloudwatch agent logging detailed --- README.md | 61 +++++++++++----------------------- alyx/alyx/settings_template.py | 13 -------- requirements.txt | 4 +-- 3 files changed, 21 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 7ca35dc4..a2a6b581 100644 --- a/README.md +++ b/README.md @@ -78,47 +78,26 @@ Location of error logs for apache if it fails to start /var/log/apache2/ -### [Optional] Setup AWS Cloudwatch logging with watchtower - -If you are running the alyx as an EC2 instance on AWS, perform the following steps to export the django logs to a -Cloudwatch log stream. Please be sure that you are in the same virtualenv as before. - -- Navigate to your AWS console and open the 'Identity and Access Management (IAM)' page -- Create an IAM Role named something along the lines of 'Alyx-Prod' and attach the following policy: -`arn:aws:iam::aws:policy/AWSOpsWorksCloudWatchLogs` -- Navigate to your EC2 instance and with the instance selected choose: Actions -> Security -> Modify IAM Role -- Attach the newly created IAM policy to your EC2 instance (instance may require reboot for metadata to populate for -boto3) -- For more thorough guides on how to create and manage your IAM Roles; working with EC2 metadata: - - [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) -- uncomment the `boto3` and `watchtower` lines in the `alyx/requirements.txt` file and rerun -`pip install -r requirements.txt` -- open your `alyx/alyx/settings.py` file, find and uncomment the following lines: - - `import boto3` - - `AWS_REGION_NAME = ` - - `boto3_logs_client = boto3.client("logs", region_name=AWS_REGION_NAME)` - - ``` - LOGGING = { - ... - 'handlers': { - ... - 'watchtower': { - 'level': 'INFO', - 'class': 'watchtower.CloudWatchLogHandler', - 'boto3_client': boto3_logs_client, - 'log_group_name': 'django_dev', - }, - }, - 'root': { - 'handlers': [ - ... - 'watchtower', - ... - ``` -- restart apache gracefully with a `apachectl -k graceful` command -- navigate to your alyx instance web page, refresh the page a few times to generate logs and take note of the time -- after a few minutes, you can verify that the newly generated logs are being written in your AWS Cloudwatch console +### [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) --- diff --git a/alyx/alyx/settings_template.py b/alyx/alyx/settings_template.py index 0372718f..e6bd8c62 100644 --- a/alyx/alyx/settings_template.py +++ b/alyx/alyx/settings_template.py @@ -10,7 +10,6 @@ import os -# import boto3 # Optional, only required if AWS Cloudwatch logging is desired import structlog from django.conf.locale.en import formats as en_formats @@ -51,10 +50,6 @@ DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000 DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -# Optional, only required if AWS Cloudwatch logging is desired -# AWS_REGION_NAME = 'eu-west-2' -# boto3_logs_client = boto3.client("logs", region_name=AWS_REGION_NAME) - LOGGING = { 'version': 1, 'disable_existing_loggers': False, @@ -99,13 +94,6 @@ 'backupCount': 5, 'formatter': 'json_formatter', }, - # Optional, watchtower entry only required for AWS Cloudwatch logging - # 'watchtower': { - # 'level': 'INFO', - # 'class': 'watchtower.CloudWatchLogHandler', - # 'boto3_client': boto3_logs_client, - # 'log_group_name': 'django_dev', - # }, }, 'loggers': { 'django': { @@ -122,7 +110,6 @@ 'handlers': [ 'file', 'console', - # 'watchtower', # Optional, watchtower entry only required for AWS Cloudwatch logging ], 'level': 'WARNING', 'propagate': True, diff --git a/requirements.txt b/requirements.txt index 4014e612..e966d443 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,4 @@ python-magic pytz webdavclient3 django-structlog -structlog~=21.5.0 -# boto3~=1.21.22 # Optional, only required if AWS Cloudwatch logging is desired -# watchtower~=3.0.0 # Optional, only required if AWS Cloudwatch logging is desired \ No newline at end of file +structlog~=21.5.0 \ No newline at end of file From 81aa6a4885d6f1e0d0e3b60039c58a7dbc7de7b0 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Tue, 22 Mar 2022 12:05:00 +0100 Subject: [PATCH 08/28] Remove automatic filling of initial cage when creating a BreedingPair --- alyx/subjects/admin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/alyx/subjects/admin.py b/alyx/subjects/admin.py index f8fb6eeb..bab01d3d 100755 --- a/alyx/subjects/admin.py +++ b/alyx/subjects/admin.py @@ -782,8 +782,11 @@ def __init__(self, *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) + # 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) From 583330ded28cb847aacf782e1628f62440a270b2 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Tue, 22 Mar 2022 12:07:48 +0100 Subject: [PATCH 09/28] Allow cage ids to be strings instead of integers --- alyx/subjects/admin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/alyx/subjects/admin.py b/alyx/subjects/admin.py index bab01d3d..112e6e25 100755 --- a/alyx/subjects/admin.py +++ b/alyx/subjects/admin.py @@ -796,7 +796,10 @@ def save(self, commit=True): for w in ('father', 'mother1', 'mother2'): p = getattr(self.instance, w, None) if p: - p.cage = int(cage) + + # 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) From eb0ec72e43018b0373e0cb225076455c4dd7c4ce Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Tue, 22 Mar 2022 12:10:25 +0100 Subject: [PATCH 10/28] Remove custom save method in breeding pair admin --- alyx/subjects/admin.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/alyx/subjects/admin.py b/alyx/subjects/admin.py index 112e6e25..be4cf2c3 100755 --- a/alyx/subjects/admin.py +++ b/alyx/subjects/admin.py @@ -790,18 +790,18 @@ def __init__(self, *args, **kwargs): 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: - - # 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) + # 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__' From a04e216a81ef0dff7ea2d7cc8a5a707f4a34320a Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Mar 2022 11:11:10 +0000 Subject: [PATCH 11/28] option to enable edition of water restrictions --- .gitignore | 2 ++ alyx/actions/admin.py | 8 ++++++++ alyx/alyx/settings_lab_template.py | 1 + 3 files changed, 11 insertions(+) diff --git a/.gitignore b/.gitignore index 6a9fa1cf..f3260b54 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/alyx/actions/admin.py b/alyx/actions/admin.py index e026465f..2da141f4 100644 --- a/alyx/actions/admin.py +++ b/alyx/actions/admin.py @@ -356,6 +356,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/alyx/settings_lab_template.py b/alyx/alyx/settings_lab_template.py index 9236fa90..655d4a9d 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}/" From dac6d8bd07ec5fa9405a39ac422a8ca76accf129 Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Tue, 22 Mar 2022 12:25:12 +0100 Subject: [PATCH 12/28] Remove automatic call to genotype_from_litter() when creating a subject or assigning it to a litter --- alyx/subjects/models.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/alyx/subjects/models.py b/alyx/subjects/models.py index 4019bdc7..73ef4294 100644 --- a/alyx/subjects/models.py +++ b/alyx/subjects/models.py @@ -336,8 +336,12 @@ def save(self, *args, **kwargs): 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. + # 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 @@ -719,6 +723,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, From 18b6ce2a4fdfec286086a3bcbc45ace18a10f9a7 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Mar 2022 11:29:42 +0000 Subject: [PATCH 13/28] fix test for zygosity from parent --- alyx/subjects/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx/subjects/tests.py b/alyx/subjects/tests.py index 6447d344..9fc2598e 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( From 63a6f8b0785657cd53d36ffd2087c439436e418b Mon Sep 17 00:00:00 2001 From: Cyrille Rossant Date: Tue, 22 Mar 2022 12:38:12 +0100 Subject: [PATCH 14/28] Automatic change of the subject's protocol number when creating Surgery or Water restriction (request by Charu) --- alyx/actions/models.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/alyx/actions/models.py b/alyx/actions/models.py index 12ef2c7e..9c382bf9 100644 --- a/alyx/actions/models.py +++ b/alyx/actions/models.py @@ -210,7 +210,8 @@ class Meta: def save(self, *args, **kwargs): # Issue #422. if self.subject.protocol_number == '1': - self.subject.protocol_number = '3' + # NOTE: changing this to 2 following request by Charu in 03/2022 + self.subject.protocol_number = '2' # Change from mild to moderate. if self.subject.actual_severity == 2: self.subject.actual_severity = 3 @@ -321,6 +322,18 @@ 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)) + + # When creating a water restriction, the subject's protocol number should be changed to 3 + # (request by Charu in 03/2022) + if self.subject: + if self.is_active(): + # Water restricted? ==> protocol number 3 + self.subject.protocol_number = '3' + else: + # Full water? ==> protocol number 2 + self.subject.protocol_number = '2' + self.subject.save() + return super(WaterRestriction, self).save(*args, **kwargs) From a21169a8a8c3b83b22235e538f4cc9ed017fd54f Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Mar 2022 11:52:04 +0000 Subject: [PATCH 15/28] weighing creation proposes all subjects for superuser or stock manager --- alyx/actions/admin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/alyx/actions/admin.py b/alyx/actions/admin.py index 2da141f4..53643cf5 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')] From d9fead7a8bbe2becc8ec3b636ad5f74be7b7299b Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Mar 2022 11:53:43 +0000 Subject: [PATCH 16/28] flake8 --- alyx/subjects/admin.py | 3 +-- alyx/subjects/models.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/alyx/subjects/admin.py b/alyx/subjects/admin.py index be4cf2c3..edc25166 100755 --- a/alyx/subjects/admin.py +++ b/alyx/subjects/admin.py @@ -781,9 +781,8 @@ 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) - # 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 diff --git a/alyx/subjects/models.py b/alyx/subjects/models.py index 73ef4294..bb062509 100644 --- a/alyx/subjects/models.py +++ b/alyx/subjects/models.py @@ -335,10 +335,9 @@ 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 - # 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) From 327f98771b7602db202852a12c532c01546fddab Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Mar 2022 12:43:05 +0000 Subject: [PATCH 17/28] auto update the protocol numbers on water restrictions and surgeries --- alyx/actions/models.py | 38 +++++++++++++++++++++----------------- alyx/subjects/models.py | 9 +++++++++ alyx/subjects/tests.py | 24 ++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/alyx/actions/models.py b/alyx/actions/models.py index 9c382bf9..f8850f1f 100644 --- a/alyx/actions/models.py +++ b/alyx/actions/models.py @@ -209,17 +209,20 @@ class Meta: def save(self, *args, **kwargs): # Issue #422. - if self.subject.protocol_number == '1': - # NOTE: changing this to 2 following request by Charu in 03/2022 - self.subject.protocol_number = '2' - # 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): @@ -315,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) @@ -322,19 +332,13 @@ 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) - if self.subject: - if self.is_active(): - # Water restricted? ==> protocol number 3 - self.subject.protocol_number = '3' - else: - # Full water? ==> protocol number 2 - self.subject.protocol_number = '2' - self.subject.save() - - return super(WaterRestriction, self).save(*args, **kwargs) + self.subject.reinit_water_control() + self.subject.set_protocol_number() + self.subject.save() + return output class OtherAction(BaseAction): diff --git a/alyx/subjects/models.py b/alyx/subjects/models.py index bb062509..49ceaa46 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__) @@ -380,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 diff --git a/alyx/subjects/tests.py b/alyx/subjects/tests.py index 9fc2598e..edf044b6 100644 --- a/alyx/subjects/tests.py +++ b/alyx/subjects/tests.py @@ -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(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): From ceb1ad174641495ac20ef8e977f50283839801a0 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Mar 2022 13:10:39 +0000 Subject: [PATCH 18/28] fix tests for cull method save overload --- alyx/actions/models.py | 9 +++++---- alyx/subjects/tests.py | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/alyx/actions/models.py b/alyx/actions/models.py index f8850f1f..77f54cd9 100644 --- a/alyx/actions/models.py +++ b/alyx/actions/models.py @@ -335,9 +335,10 @@ def save(self, *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) - self.subject.reinit_water_control() - self.subject.set_protocol_number() - self.subject.save() + if self.subject: + self.subject.reinit_water_control() + self.subject.set_protocol_number() + self.subject.save() return output @@ -560,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/subjects/tests.py b/alyx/subjects/tests.py index edf044b6..c8d69627 100644 --- a/alyx/subjects/tests.py +++ b/alyx/subjects/tests.py @@ -250,7 +250,7 @@ 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(self): + def test_protocol_number(self): from actions.models import Surgery assert self.sub.protocol_number == '1' # after a surgery protocol number goes to 2 @@ -284,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 From 93153cae30cd266168d13e066c507a7c25e0878d Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 29 Mar 2022 16:19:39 +0100 Subject: [PATCH 19/28] WIP custom REST permissions public user --- alyx/actions/views.py | 19 ++++++++++--------- alyx/alyx/base.py | 17 +++++++++++++++++ .../0010_labmember_is_public_user.py | 18 ++++++++++++++++++ alyx/misc/models.py | 1 + 4 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 alyx/misc/migrations/0010_labmember_is_public_user.py diff --git a/alyx/actions/views.py b/alyx/actions/views.py index 0e80b8aa..45bf2c9e 100644 --- a/alyx/actions/views.py +++ b/alyx/actions/views.py @@ -16,7 +16,7 @@ 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() @@ -456,7 +457,7 @@ 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' diff --git a/alyx/alyx/base.py b/alyx/alyx/base.py index eea4372f..aa3ccb63 100644 --- a/alyx/alyx/base.py +++ b/alyx/alyx/base.py @@ -24,6 +24,7 @@ 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 @@ -317,6 +318,8 @@ def changelist_view(self, request, extra_context=None): return super(BaseAdmin, self).changelist_view(request, extra_context=extra_context) 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: @@ -567,6 +570,20 @@ def rest_filters_exception_handler(exc, context): return response +class BaseRestPublicPermission(permissions.BasePermission): + + def has_permission(self, request, view): + if 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' 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 00000000..53d596c9 --- /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-29 16:00 + +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(blank=True, default=False), + ), + ] diff --git a/alyx/misc/models.py b/alyx/misc/models.py index 6d6db80f..7c43ce72 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'] From 6b85e922ce0b710b22a3f333a86d5189312a71e3 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 29 Mar 2022 18:25:58 +0100 Subject: [PATCH 20/28] REST public user permissions --- alyx/actions/views.py | 10 +++++----- alyx/alyx/base.py | 8 ++++++-- alyx/data/views.py | 40 +++++++++++++++++++-------------------- alyx/experiments/views.py | 20 ++++++++++---------- alyx/jobs/views.py | 8 ++++---- alyx/misc/tests_rest.py | 10 ++++++++++ alyx/misc/views.py | 20 ++++++++++---------- alyx/subjects/views.py | 14 +++++++------- 8 files changed, 72 insertions(+), 58 deletions(-) diff --git a/alyx/actions/views.py b/alyx/actions/views.py index 45bf2c9e..1094793d 100644 --- a/alyx/actions/views.py +++ b/alyx/actions/views.py @@ -12,7 +12,7 @@ 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 @@ -414,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 @@ -440,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 @@ -450,7 +450,7 @@ class LabLocationList(generics.ListAPIView): """ queryset = LabLocation.objects.all() serializer_class = LabLocationSerializer - permission_classes = (permissions.IsAuthenticated,) + permission_classes = rest_permission_classes() class LabLocationAPIDetails(generics.RetrieveUpdateAPIView): @@ -469,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/base.py b/alyx/alyx/base.py index aa3ccb63..80465c4a 100644 --- a/alyx/alyx/base.py +++ b/alyx/alyx/base.py @@ -571,9 +571,13 @@ def rest_filters_exception_handler(exc, context): 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.user.is_public_user: + if request.method == 'GET': + return True + elif request.user.is_public_user: return False else: return True diff --git a/alyx/data/views.py b/alyx/data/views.py index 35e65ef1..0d22b74d 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/views.py b/alyx/experiments/views.py index 75a5867a..762849ef 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 f5033c28..d4e30dd2 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/tests_rest.py b/alyx/misc/tests_rest.py index e1287d2b..f66ba891 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,14 @@ 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) + assert False + 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 7d293ea0..5964c8fb 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/views.py b/alyx/subjects/views.py index feb9aa86..42353c4f 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() From 4261a9f4c4a01a87211aca97c2b8e48931621f7c Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 29 Mar 2022 18:39:45 +0100 Subject: [PATCH 21/28] I wish this commit could disappear --- alyx/misc/tests_rest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/alyx/misc/tests_rest.py b/alyx/misc/tests_rest.py index f66ba891..6eb87ddc 100644 --- a/alyx/misc/tests_rest.py +++ b/alyx/misc/tests_rest.py @@ -45,7 +45,6 @@ def test_public_user(self): self.client.force_login(user=self.public_user) response = self.post(reverse('lab-list'), {'name': 'prank'}) self.ar(response, 403) - assert False def test_user_rest(self): response = self.client.get(reverse('user-list') + '/test') From f70561e79787c1fc43deb0bcbecbf60300af8b56 Mon Sep 17 00:00:00 2001 From: olivier Date: Wed, 30 Mar 2022 15:35:07 +0100 Subject: [PATCH 22/28] add migrations --- alyx/misc/migrations/0010_labmember_is_public_user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alyx/misc/migrations/0010_labmember_is_public_user.py b/alyx/misc/migrations/0010_labmember_is_public_user.py index 53d596c9..d2d71057 100644 --- a/alyx/misc/migrations/0010_labmember_is_public_user.py +++ b/alyx/misc/migrations/0010_labmember_is_public_user.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.4 on 2022-03-29 16:00 +# Generated by Django 3.2.4 on 2022-03-30 15:34 from django.db import migrations, models @@ -13,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='labmember', name='is_public_user', - field=models.BooleanField(blank=True, default=False), + field=models.BooleanField(default=False), ), ] From a94a6f31e1ea954823316d8ea2a42dc06b1df578 Mon Sep 17 00:00:00 2001 From: juhuntenburg Date: Wed, 30 Mar 2022 15:49:30 +0100 Subject: [PATCH 23/28] migrations for django 4 --- ...alter_chronicrecording_subject_and_more.py | 45 +++++++++++++++++++ .../0010_alter_dataset_created_by_and_more.py | 37 +++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 alyx/actions/migrations/0017_alter_chronicrecording_subject_and_more.py create mode 100644 alyx/data/migrations/0010_alter_dataset_created_by_and_more.py 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 00000000..ab871ece --- /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/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 00000000..f540cf8a --- /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), + ), + ] From a5eb359de16b8733888ed4e504debf719953e8a2 Mon Sep 17 00:00:00 2001 From: juhuntenburg Date: Wed, 30 Mar 2022 16:13:23 +0100 Subject: [PATCH 24/28] tickbox for public user --- alyx/subjects/admin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/alyx/subjects/admin.py b/alyx/subjects/admin.py index edc25166..5498d546 100755 --- a/alyx/subjects/admin.py +++ b/alyx/subjects/admin.py @@ -1292,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',)}), @@ -1302,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): From 97fb5aa920d2282d905376cb22c3809b4c43d557 Mon Sep 17 00:00:00 2001 From: juhuntenburg Date: Wed, 30 Mar 2022 16:25:48 +0100 Subject: [PATCH 25/28] restrict permissions when no related subject found --- alyx/alyx/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/alyx/alyx/base.py b/alyx/alyx/base.py index 80465c4a..ca9973da 100644 --- a/alyx/alyx/base.py +++ b/alyx/alyx/base.py @@ -319,6 +319,7 @@ def changelist_view(self, request, extra_context=None): def has_change_permission(self, request, obj=None): if request.user.is_public_user: + print('toto') return False if not obj: return True @@ -331,6 +332,8 @@ def has_change_permission(self, request, obj=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) From 6bda96f36b778608a520332a58a9dee98eaa60c0 Mon Sep 17 00:00:00 2001 From: juhuntenburg Date: Wed, 30 Mar 2022 16:29:10 +0100 Subject: [PATCH 26/28] remove toto --- alyx/alyx/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/alyx/alyx/base.py b/alyx/alyx/base.py index ca9973da..b48f507b 100644 --- a/alyx/alyx/base.py +++ b/alyx/alyx/base.py @@ -319,7 +319,6 @@ def changelist_view(self, request, extra_context=None): def has_change_permission(self, request, obj=None): if request.user.is_public_user: - print('toto') return False if not obj: return True From 4ce3487156a054a541c16f54dde6c53cef6e6e1f Mon Sep 17 00:00:00 2001 From: juhuntenburg Date: Wed, 30 Mar 2022 16:39:19 +0100 Subject: [PATCH 27/28] remove right to add for public user --- alyx/alyx/base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/alyx/alyx/base.py b/alyx/alyx/base.py index b48f507b..87784ddf 100644 --- a/alyx/alyx/base.py +++ b/alyx/alyx/base.py @@ -317,6 +317,12 @@ 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 From 0c52378e1da286e70ef7b5b195e7a9e73a885be0 Mon Sep 17 00:00:00 2001 From: olivier Date: Mon, 4 Apr 2022 10:21:34 +0100 Subject: [PATCH 28/28] versioning visible in admin interface --- alyx/alyx/__init__.py | 2 ++ alyx/alyx/base.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/alyx/alyx/__init__.py b/alyx/alyx/__init__.py index e69de29b..ef3f584d 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 87784ddf..d28f9376 100644 --- a/alyx/alyx/base.py +++ b/alyx/alyx/base.py @@ -27,7 +27,7 @@ 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__) @@ -600,7 +600,7 @@ def rest_permission_classes(): 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