+
+
+ {% with pending_applications=event|get_pending_applications %}
+ {% for application in pending_applications %}
+
+
{{ application.user.username }}
+
{{ application.user.get_full_name }}
+
{{ application.user.email }}
+
{{ application.comment_to_applicant }}
+
{{ application.decision_datetime | date }}
+
+ {% endfor %}
+ {% endwith %}
+
+
+
+
+
\ No newline at end of file
diff --git a/physionet-django/events/templates/events/event_rejected_applications.html b/physionet-django/events/templates/events/event_rejected_applications.html
new file mode 100644
index 0000000000..66168ece86
--- /dev/null
+++ b/physionet-django/events/templates/events/event_rejected_applications.html
@@ -0,0 +1,31 @@
+{% load participation_status %}
+
+
+
+
+
+
+
Username
+
Full name
+
Email
+
Credentialed
+
Cohost
+
+
+
+ {% with rejected_applications=event|get_rejected_applications %}
+ {% for application in rejected_applications %}
+
+
{{ application.user.username }}
+
{{ application.user.get_full_name }}
+
{{ application.user.email }}
+
{{ application.comment_to_applicant }}
+
{{ application.decision_datetime | date }}
+
+ {% endfor %}
+ {% endwith %}
+
+
+
+
+
\ No newline at end of file
diff --git a/physionet-django/events/templates/events/event_withdrawn_applications.html b/physionet-django/events/templates/events/event_withdrawn_applications.html
new file mode 100644
index 0000000000..2769601437
--- /dev/null
+++ b/physionet-django/events/templates/events/event_withdrawn_applications.html
@@ -0,0 +1,31 @@
+{% load participation_status %}
+
+
+
+
+
+
+
Username
+
Full name
+
Email
+
Credentialed
+
Cohost
+
+
+
+ {% with withrawn_applications=event|get_withdrawn_applications %}
+ {% for application in withdrawn_applications %}
+
{% if events_active %}
{% for event in events_active %}
- {% for option in event_details|get_status_options:event.id %}
-
+ {% for info in event_details|get_applicant_info:event.id %}
+
-
{{ option.title }}
+
{{ info.title }}
×
@@ -175,12 +175,12 @@
{{ event.title }}
{% if events_past %}
{% for event in events_past %}
- {% for option in event_details|get_status_options:event.id %}
-
+ {% for info in event_details|get_applicant_info:event.id %}
+
-
{{ option.title }}
+
{{ info.title }}
×
diff --git a/physionet-django/events/templatetags/participation_status.py b/physionet-django/events/templatetags/participation_status.py
index d8f90de30d..d67bbfd797 100644
--- a/physionet-django/events/templatetags/participation_status.py
+++ b/physionet-django/events/templatetags/participation_status.py
@@ -1,30 +1,32 @@
from django import template
from events.models import EventApplication
-from project.authorization.events import has_access_to_event_dataset as has_access_to_event_dataset_func
+from project.authorization.events import (
+ has_access_to_event_dataset as has_access_to_event_dataset_func,
+)
register = template.Library()
-@register.filter(name='is_participant')
+@register.filter(name="is_participant")
def is_participant(user, event):
return event.participants.filter(user=user).exists()
-@register.filter(name='is_on_waiting_list')
+@register.filter(name="is_on_waiting_list")
def is_on_waiting_list(user, event):
return EventApplication.objects.filter(
user=user,
event=event,
- status=EventApplication.EventApplicationStatus.WAITLISTED
+ status=EventApplication.EventApplicationStatus.WAITLISTED,
).exists()
-@register.filter(name='has_access_to_event_dataset')
+@register.filter(name="has_access_to_event_dataset")
def has_access_to_event_dataset(user, dataset):
return has_access_to_event_dataset_func(user, dataset)
-@register.filter(name='get_status_options')
-def get_status_options(event_details, event_id):
+@register.filter(name="get_applicant_info")
+def get_applicant_info(event_details, event_id):
return event_details[event_id]
diff --git a/physionet-django/events/views.py b/physionet-django/events/views.py
index b18e68802f..e42deb7c53 100644
--- a/physionet-django/events/views.py
+++ b/physionet-django/events/views.py
@@ -1,10 +1,9 @@
from datetime import datetime
-import stat
from django.http import JsonResponse
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
-from django.db.models import Q
+from django.db.models import Q, Prefetch
from django.contrib.auth.decorators import login_required
from django.forms import modelformset_factory
from django.urls import reverse
@@ -80,16 +79,19 @@ def event_home(request):
List of events
"""
user = request.user
- can_change_event = user.has_perm('events.add_event')
+ can_change_event = user.has_perm("events.add_event")
- EventApplicationResponseFormSet = modelformset_factory(EventApplication,
- form=EventApplicationResponseForm, extra=0)
+ EventApplicationResponseFormSet = modelformset_factory(
+ EventApplication, form=EventApplicationResponseForm, extra=0
+ )
# sqlite doesn't support the distinct() method
events_all = Event.objects.filter(Q(host=user) | Q(participants__user=user))
# concatenate the events where the user is the host,participant and the events where the user is on the waitlist
events_all = events_all | Event.objects.filter(
- applications__user=user, applications__status=EventApplication.EventApplicationStatus.WAITLISTED)
+ applications__user=user,
+ applications__status=EventApplication.EventApplicationStatus.WAITLISTED,
+ )
events_active = set(events_all.filter(end_date__gte=datetime.now()))
events_past = set(events_all.filter(end_date__lt=datetime.now()))
@@ -100,12 +102,11 @@ def event_home(request):
form_error = False
# handle notifications to join an event
- if request.method == 'POST' and 'participation_response' in request.POST.keys():
-
+ if request.method == "POST" and "participation_response" in request.POST.keys():
formset = EventApplicationResponseFormSet(request.POST)
# only process the form that was submitted
for form in formset:
- if form.instance.id != int(request.POST['participation_response']):
+ if form.instance.id != int(request.POST["participation_response"]):
continue
if not form.is_valid():
@@ -115,91 +116,123 @@ def event_home(request):
event_application = form.save(commit=False)
event = event_application.event
if event.host != user:
- messages.error(request, "You don't have permission to accept/reject this application")
+ messages.error(
+ request,
+ "You don't have permission to accept/reject this application",
+ )
return redirect(event_home)
- elif event_application.status == EventApplication.EventApplicationStatus.APPROVED:
- if EventParticipant.objects.filter(event=event, user=event_application.user).exists():
+ elif (
+ event_application.status
+ == EventApplication.EventApplicationStatus.APPROVED
+ ):
+ if EventParticipant.objects.filter(
+ event=event, user=event_application.user
+ ).exists():
messages.error(request, "Application was already approved")
return redirect(event_home)
event_application.accept(
- comment_to_applicant=form.cleaned_data.get('comment_to_applicant')
+ comment_to_applicant=form.cleaned_data.get("comment_to_applicant")
)
notification.notify_participant_event_decision(
request=request,
user=event_application.user,
event=event_application.event,
decision=EventApplication.EventApplicationStatus.APPROVED.label,
- comment_to_applicant=form.cleaned_data.get('comment_to_applicant')
+ comment_to_applicant=form.cleaned_data.get("comment_to_applicant"),
)
- elif event_application.status == EventApplication.EventApplicationStatus.NOT_APPROVED:
+ elif (
+ event_application.status
+ == EventApplication.EventApplicationStatus.NOT_APPROVED
+ ):
event_application.reject(
- comment_to_applicant=form.cleaned_data.get('comment_to_applicant')
+ comment_to_applicant=form.cleaned_data.get("comment_to_applicant")
)
notification.notify_participant_event_decision(
request=request,
user=event_application.user,
event=event_application.event,
decision=EventApplication.EventApplicationStatus.NOT_APPROVED.label,
- comment_to_applicant=form.cleaned_data.get('comment_to_applicant')
+ comment_to_applicant=form.cleaned_data.get("comment_to_applicant"),
)
return redirect(event_home)
else:
form_error = True
+ events_all = Event.objects.all().prefetch_related(
+ "participants",
+ "applications",
+ )
+
event_details = {}
for selected_event in events_all:
- participants = selected_event.participants.all()
- pending_applications = selected_event.applications.filter(
- status=EventApplication.EventApplicationStatus.WAITLISTED)
- rejected_applications = selected_event.applications.filter(
- status__in=[EventApplication.EventApplicationStatus.NOT_APPROVED])
- withdrawn_applications = selected_event.applications.filter(
- status__in=[EventApplication.EventApplicationStatus.WITHDRAWN])
+ all_applications = selected_event.applications.all()
+ pending_applications = [
+ app
+ for app in all_applications
+ if app.status == EventApplication.EventApplicationStatus.WAITLISTED
+ ]
+ rejected_applications = [
+ app
+ for app in all_applications
+ if app.status == EventApplication.EventApplicationStatus.NOT_APPROVED
+ ]
+ withdrawn_applications = [
+ app
+ for app in all_applications
+ if app.status == EventApplication.EventApplicationStatus.WITHDRAWN
+ ]
event_details[selected_event.id] = [
{
- 'id': 'participants',
- 'title': 'Total participants:',
- 'count': participants.count(),
- 'objects': participants,
+ "id": "participants",
+ "title": "Total participants:",
+ "count": len(selected_event.participants.all()),
+ "objects": selected_event.participants.all(),
},
{
- 'id': 'pending_applications',
- 'title': 'Pending applications:',
- 'count': pending_applications.count(),
- 'objects': pending_applications,
+ "id": "pending_applications",
+ "title": "Pending applications:",
+ "count": len(pending_applications),
+ "objects": pending_applications,
},
{
- 'id': 'rejected_applications',
- 'title': 'Rejected applications:',
- 'count': rejected_applications.count(),
- 'objects': rejected_applications,
+ "id": "rejected_applications",
+ "title": "Rejected applications:",
+ "count": len(rejected_applications),
+ "objects": rejected_applications,
},
{
- 'id': 'withdrawn_applications',
- 'title': 'Withdrawn applications:',
- 'count': withdrawn_applications.count(),
- 'objects': withdrawn_applications,
+ "id": "withdrawn_applications",
+ "title": "Withdrawn applications:",
+ "count": len(withdrawn_applications),
+ "objects": withdrawn_applications,
},
]
# get all participation requests for Active events where the current user is the host and the participants are
# waiting for a response
participation_requests = EventApplication.objects.filter(
- status=EventApplication.EventApplicationStatus.WAITLISTED).filter(event__host=user,
- event__end_date__gte=datetime.now())
- participation_response_formset = EventApplicationResponseFormSet(queryset=participation_requests)
- return render(request, 'events/event_home.html',
- {'events_active': events_active,
- 'events_past': events_past,
- 'event_form': event_form,
- 'url_prefix': url_prefix,
- 'can_change_event': can_change_event,
- 'form_error': form_error,
- 'participation_response_formset': participation_response_formset,
- 'event_details': event_details,
- })
+ status=EventApplication.EventApplicationStatus.WAITLISTED
+ ).filter(event__host=user, event__end_date__gte=datetime.now())
+ participation_response_formset = EventApplicationResponseFormSet(
+ queryset=participation_requests
+ )
+ return render(
+ request,
+ "events/event_home.html",
+ {
+ "events_active": events_active,
+ "events_past": events_past,
+ "event_form": event_form,
+ "url_prefix": url_prefix,
+ "can_change_event": can_change_event,
+ "form_error": form_error,
+ "participation_response_formset": participation_response_formset,
+ "event_details": event_details,
+ },
+ )
+
@login_required
@@ -276,4 +309,4 @@ def event_detail(request, event_slug):
'registration_error_message': registration_error_message,
'is_waitlisted': is_waitlisted,
'event_datasets': event_datasets,
- })
+ })
\ No newline at end of file
From 8a515772570d227eb9c2076b82bfaf5d61476a27 Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Tue, 7 Nov 2023 08:43:19 -0500
Subject: [PATCH 010/181] Fixing styling Issues
---
physionet-django/console/views.py | 2 +-
physionet-django/events/views.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/physionet-django/console/views.py b/physionet-django/console/views.py
index 23ddb60c70..d8d2d334ba 100644
--- a/physionet-django/console/views.py
+++ b/physionet-django/console/views.py
@@ -3164,4 +3164,4 @@ def event_agreement_delete(request, pk):
event_agreement.delete()
messages.success(request, "The Event Agreement has been deleted.")
- return redirect("event_agreement_list")
\ No newline at end of file
+ return redirect("event_agreement_list")
diff --git a/physionet-django/events/views.py b/physionet-django/events/views.py
index e42deb7c53..6c247f3755 100644
--- a/physionet-django/events/views.py
+++ b/physionet-django/events/views.py
@@ -309,4 +309,4 @@ def event_detail(request, event_slug):
'registration_error_message': registration_error_message,
'is_waitlisted': is_waitlisted,
'event_datasets': event_datasets,
- })
\ No newline at end of file
+ })
From cff683f2f643653edaebbf84d5397054a7c9999c Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Tue, 7 Nov 2023 09:32:52 -0500
Subject: [PATCH 011/181] addressing comments on imports & naming
---
physionet-django/console/views.py | 14 +++++---------
physionet-django/events/views.py | 20 ++++++++++----------
2 files changed, 15 insertions(+), 19 deletions(-)
diff --git a/physionet-django/console/views.py b/physionet-django/console/views.py
index d8d2d334ba..afcb8df628 100644
--- a/physionet-django/console/views.py
+++ b/physionet-django/console/views.py
@@ -5,7 +5,6 @@
from collections import OrderedDict
from datetime import datetime
from itertools import chain
-import stat
from statistics import StatisticsError, median
import notification.utility as notification
@@ -2989,14 +2988,11 @@ def event_management(request, event_slug):
if "add-event-dataset" in request.POST.keys():
event_dataset_form = EventDatasetForm(request.POST)
if event_dataset_form.is_valid():
- if (
- selected_event.datasets.filter(
- dataset=event_dataset_form.cleaned_data["dataset"],
- access_type=event_dataset_form.cleaned_data["access_type"],
- is_active=True,
- ).count()
- == 0
- ):
+ active_datasets = selected_event.datasets.filter(
+ dataset=event_dataset_form.cleaned_data["dataset"],
+ access_type=event_dataset_form.cleaned_data["access_type"],
+ is_active=True)
+ if len(active_datasets) == 0:
event_dataset_form.instance.event = selected_event
event_dataset_form.save()
messages.success(
diff --git a/physionet-django/events/views.py b/physionet-django/events/views.py
index 6c247f3755..d4d8e1a1b9 100644
--- a/physionet-django/events/views.py
+++ b/physionet-django/events/views.py
@@ -3,7 +3,7 @@
from django.http import JsonResponse
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
-from django.db.models import Q, Prefetch
+from django.db.models import Q
from django.contrib.auth.decorators import login_required
from django.forms import modelformset_factory
from django.urls import reverse
@@ -168,19 +168,19 @@ def event_home(request):
for selected_event in events_all:
all_applications = selected_event.applications.all()
pending_applications = [
- app
- for app in all_applications
- if app.status == EventApplication.EventApplicationStatus.WAITLISTED
+ application
+ for application in all_applications
+ if application.status == EventApplication.EventApplicationStatus.WAITLISTED
]
rejected_applications = [
- app
- for app in all_applications
- if app.status == EventApplication.EventApplicationStatus.NOT_APPROVED
+ application
+ for application in all_applications
+ if application.status == EventApplication.EventApplicationStatus.NOT_APPROVED
]
withdrawn_applications = [
- app
- for app in all_applications
- if app.status == EventApplication.EventApplicationStatus.WITHDRAWN
+ application
+ for application in all_applications
+ if application.status == EventApplication.EventApplicationStatus.WITHDRAWN
]
event_details[selected_event.id] = [
From 51a19837a4856bc99a7760060333c08878f045fa Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Tue, 7 Nov 2023 10:18:14 -0500
Subject: [PATCH 012/181] fixed event in view
---
physionet-django/events/views.py | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/physionet-django/events/views.py b/physionet-django/events/views.py
index d4d8e1a1b9..6d05a53dd6 100644
--- a/physionet-django/events/views.py
+++ b/physionet-django/events/views.py
@@ -159,14 +159,14 @@ def event_home(request):
else:
form_error = True
- events_all = Event.objects.all().prefetch_related(
+ events = Event.objects.all().prefetch_related(
"participants",
"applications",
)
event_details = {}
- for selected_event in events_all:
- all_applications = selected_event.applications.all()
+ for event in events:
+ all_applications = event.applications.all()
pending_applications = [
application
for application in all_applications
@@ -183,12 +183,12 @@ def event_home(request):
if application.status == EventApplication.EventApplicationStatus.WITHDRAWN
]
- event_details[selected_event.id] = [
+ event_details[event.id] = [
{
"id": "participants",
"title": "Total participants:",
- "count": len(selected_event.participants.all()),
- "objects": selected_event.participants.all(),
+ "count": len(event.participants.all()),
+ "objects": event.participants.all(),
},
{
"id": "pending_applications",
From 07ca6302d1a6087915e87ecdc1610829a58183ad Mon Sep 17 00:00:00 2001
From: Benjamin Moody
Date: Tue, 7 Nov 2023 17:17:41 -0500
Subject: [PATCH 013/181] Add dev dependency on django-coverage-plugin.
This is a little package that extends coverage.py, to allow measuring
and reporting test coverage for Django template files.
https://github.com/nedbat/django_coverage_plugin
---
poetry.lock | 121 +++++++----------------------------------------
pyproject.toml | 1 +
requirements.txt | 35 +++-----------
3 files changed, 26 insertions(+), 131 deletions(-)
diff --git a/poetry.lock b/poetry.lock
index 75a1b97bc8..43aeedf2ab 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,10 +1,9 @@
-# This file is automatically @generated by Poetry and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand.
[[package]]
name = "asgiref"
version = "3.5.2"
description = "ASGI specs, helper code, and adapters"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -19,7 +18,6 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
name = "bleach"
version = "3.3.0"
description = "An easy safelist-based HTML-sanitizing tool."
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = [
@@ -36,7 +34,6 @@ webencodings = "*"
name = "boto3"
version = "1.28.53"
description = "The AWS SDK for Python"
-category = "main"
optional = false
python-versions = ">= 3.7"
files = [
@@ -56,7 +53,6 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
name = "botocore"
version = "1.31.53"
description = "Low-level, data-driven core of boto 3."
-category = "main"
optional = false
python-versions = ">= 3.7"
files = [
@@ -76,7 +72,6 @@ crt = ["awscrt (==0.16.26)"]
name = "cachetools"
version = "4.2.2"
description = "Extensible memoizing collections and decorators"
-category = "main"
optional = false
python-versions = "~=3.5"
files = [
@@ -88,7 +83,6 @@ files = [
name = "certifi"
version = "2023.7.22"
description = "Python package for providing Mozilla's CA Bundle."
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -100,7 +94,6 @@ files = [
name = "cffi"
version = "1.15.1"
description = "Foreign Function Interface for Python calling C code."
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -177,7 +170,6 @@ pycparser = "*"
name = "chardet"
version = "3.0.4"
description = "Universal encoding detector for Python 2 and 3"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -189,7 +181,6 @@ files = [
name = "charset-normalizer"
version = "2.0.12"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
-category = "main"
optional = false
python-versions = ">=3.5.0"
files = [
@@ -204,7 +195,6 @@ unicode-backport = ["unicodedata2"]
name = "coverage"
version = "7.2.3"
description = "Code coverage measurement for Python"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -268,7 +258,6 @@ toml = ["tomli"]
name = "cryptography"
version = "41.0.4"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -314,7 +303,6 @@ test-randomorder = ["pytest-randomly"]
name = "deprecated"
version = "1.2.13"
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -332,7 +320,6 @@ dev = ["PyTest", "PyTest (<5)", "PyTest-Cov", "PyTest-Cov (<2.6)", "bump2version
name = "django"
version = "4.1.13"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
-category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -353,7 +340,6 @@ bcrypt = ["bcrypt"]
name = "django-autocomplete-light"
version = "3.9.4"
description = "Fresh autocompletes for Django"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -373,7 +359,6 @@ tags = ["django-taggit"]
name = "django-background-tasks-updated"
version = "1.2.7"
description = "Database backed asynchronous task queue"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -389,7 +374,6 @@ six = "*"
name = "django-ckeditor"
version = "6.5.1"
description = "Django admin CKEditor integration."
-category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -405,7 +389,6 @@ django-js-asset = ">=2.0"
name = "django-cors-headers"
version = "3.14.0"
description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)."
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -416,11 +399,24 @@ files = [
[package.dependencies]
Django = ">=3.2"
+[[package]]
+name = "django-coverage-plugin"
+version = "3.1.0"
+description = "Django template coverage.py plugin"
+optional = false
+python-versions = "*"
+files = [
+ {file = "django_coverage_plugin-3.1.0-py3-none-any.whl", hash = "sha256:eb0ea8ffdb0db11a02994fc99be6500550efb496c350d709f418ff3d8e553a67"},
+ {file = "django_coverage_plugin-3.1.0.tar.gz", hash = "sha256:223d34bf92bebadcb8b7b89932480e41c7bd98b44a8156934488fbe7f4a71f99"},
+]
+
+[package.dependencies]
+coverage = "*"
+
[[package]]
name = "django-debug-toolbar"
version = "3.2.4"
description = "A configurable set of panels that display various debug information about the current request/response."
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -436,7 +432,6 @@ sqlparse = ">=0.2.0"
name = "django-js-asset"
version = "2.0.0"
description = "script tag with additional attributes for django.forms.Media"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -454,7 +449,6 @@ tests = ["coverage"]
name = "django-oauth-toolkit"
version = "2.2.0"
description = "OAuth2 Provider for Django"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -472,7 +466,6 @@ requests = ">=2.13.0"
name = "django-sass"
version = "1.1.0"
description = "The absolute simplest way to use Sass with Django. Pure Python, minimal dependencies, and no special configuration required!"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -488,7 +481,6 @@ libsass = "*"
name = "django-storages"
version = "1.12.3"
description = "Support for many storage backends in Django"
-category = "main"
optional = false
python-versions = ">=3.5"
files = [
@@ -512,7 +504,6 @@ sftp = ["paramiko"]
name = "djangorestframework"
version = "3.14.0"
description = "Web APIs for Django, made easy."
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -528,7 +519,6 @@ pytz = "*"
name = "google-api-core"
version = "1.34.0"
description = "Google API client core library"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -553,7 +543,6 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0dev)"]
name = "google-api-python-client"
version = "1.12.8"
description = "Google API Client Library for Python"
-category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
files = [
@@ -573,7 +562,6 @@ uritemplate = ">=3.0.0,<4dev"
name = "google-auth"
version = "1.32.0"
description = "Google Authentication Library"
-category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
files = [
@@ -597,7 +585,6 @@ reauth = ["pyu2f (>=0.1.5)"]
name = "google-auth-httplib2"
version = "0.1.0"
description = "Google Authentication Library: httplib2 transport"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -614,7 +601,6 @@ six = "*"
name = "google-cloud-core"
version = "1.7.0"
description = "Google Cloud API client core library"
-category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
files = [
@@ -634,7 +620,6 @@ grpc = ["grpcio (>=1.8.2,<2.0dev)"]
name = "google-cloud-storage"
version = "1.42.3"
description = "Google Cloud Storage API client library"
-category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
files = [
@@ -655,7 +640,6 @@ six = "*"
name = "google-cloud-workflows"
version = "1.9.1"
description = "Google Cloud Workflows API client library"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -664,10 +648,10 @@ files = [
]
[package.dependencies]
-google-api-core = {version = ">=1.34.0,<2.0.0 || >=2.11.0,<3.0.0dev", extras = ["grpc"]}
+google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
proto-plus = [
- {version = ">=1.22.0,<2.0.0dev", markers = "python_version < \"3.11\""},
{version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""},
+ {version = ">=1.22.0,<2.0.0dev", markers = "python_version < \"3.11\""},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev"
@@ -675,7 +659,6 @@ protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4
name = "google-crc32c"
version = "1.1.2"
description = "A python wrapper of the C library 'Google CRC32C'"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -720,7 +703,6 @@ testing = ["pytest"]
name = "google-resumable-media"
version = "1.3.1"
description = "Utilities for Google Media Downloads and Resumable Uploads"
-category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
files = [
@@ -740,7 +722,6 @@ requests = ["requests (>=2.18.0,<3.0.0dev)"]
name = "googleapis-common-protos"
version = "1.58.0"
description = "Common protobufs used in Google APIs"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -758,7 +739,6 @@ grpc = ["grpcio (>=1.44.0,<2.0.0dev)"]
name = "grpcio"
version = "1.53.0"
description = "HTTP/2-based RPC framework"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -816,7 +796,6 @@ protobuf = ["grpcio-tools (>=1.53.0)"]
name = "grpcio-status"
version = "1.48.2"
description = "Status proto mapping for gRPC"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -833,7 +812,6 @@ protobuf = ">=3.12.0"
name = "hdn-research-environment"
version = "2.3.8"
description = "A Django app for supporting cloud-native research environments"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -849,7 +827,6 @@ google-cloud-workflows = ">=1.6.1"
name = "html2text"
version = "2018.1.9"
description = "Turn HTML into equivalent Markdown-structured text."
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -861,7 +838,6 @@ files = [
name = "httplib2"
version = "0.19.1"
description = "A comprehensive HTTP client library."
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -876,7 +852,6 @@ pyparsing = ">=2.4.2,<3"
name = "idna"
version = "2.10"
description = "Internationalized Domain Names in Applications (IDNA)"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -888,7 +863,6 @@ files = [
name = "jmespath"
version = "1.0.1"
description = "JSON Matching Expressions"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -900,7 +874,6 @@ files = [
name = "jwcrypto"
version = "1.4.2"
description = "Implementation of JOSE Web standards"
-category = "main"
optional = false
python-versions = ">= 3.6"
files = [
@@ -915,7 +888,6 @@ deprecated = "*"
name = "libsass"
version = "0.21.0"
description = "Sass for Python: A straightforward binding of libsass for Python."
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -938,7 +910,6 @@ six = "*"
name = "oauthlib"
version = "3.2.2"
description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -955,7 +926,6 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
name = "packaging"
version = "20.9"
description = "Core utilities for Python packages"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -970,7 +940,6 @@ pyparsing = ">=2.0.2"
name = "pdfminer.six"
version = "20211012"
description = "PDF parser and analyzer"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -990,7 +959,6 @@ docs = ["sphinx", "sphinx-argparse"]
name = "pillow"
version = "10.0.1"
description = "Python Imaging Library (Fork)"
-category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -1058,7 +1026,6 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
name = "proto-plus"
version = "1.22.2"
description = "Beautiful, Pythonic protocol buffers."
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -1076,11 +1043,9 @@ testing = ["google-api-core[grpc] (>=1.31.5)"]
name = "protobuf"
version = "3.20.3"
description = "Protocol Buffers"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
- {file = "protobuf-3.20.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e67f9af1b607eb3a89aafc9bc68a9d1172aae788b2445cb9fd781bd97531f1f1"},
{file = "protobuf-3.20.3-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:f4bd856d702e5b0d96a00ec6b307b0f51c1982c2bf9c0052cf9019e9a544ba99"},
{file = "protobuf-3.20.3-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9aae4406ea63d825636cc11ffb34ad3379335803216ee3a856787bcf5ccc751e"},
{file = "protobuf-3.20.3-cp310-cp310-win32.whl", hash = "sha256:28545383d61f55b57cf4df63eebd9827754fd2dc25f80c5253f9184235db242c"},
@@ -1109,7 +1074,6 @@ files = [
name = "psycopg2"
version = "2.9.5"
description = "psycopg2 - Python-PostgreSQL Database Adapter"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -1132,22 +1096,10 @@ files = [
name = "pyasn1"
version = "0.4.8"
description = "ASN.1 types and codecs"
-category = "main"
optional = false
python-versions = "*"
files = [
- {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"},
- {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"},
- {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"},
- {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"},
{file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"},
- {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"},
- {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"},
- {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"},
- {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"},
- {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"},
- {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"},
- {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"},
{file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"},
]
@@ -1155,23 +1107,11 @@ files = [
name = "pyasn1-modules"
version = "0.2.8"
description = "A collection of ASN.1-based protocols modules."
-category = "main"
optional = false
python-versions = "*"
files = [
{file = "pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"},
- {file = "pyasn1_modules-0.2.8-py2.4.egg", hash = "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199"},
- {file = "pyasn1_modules-0.2.8-py2.5.egg", hash = "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405"},
- {file = "pyasn1_modules-0.2.8-py2.6.egg", hash = "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb"},
- {file = "pyasn1_modules-0.2.8-py2.7.egg", hash = "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8"},
{file = "pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"},
- {file = "pyasn1_modules-0.2.8-py3.1.egg", hash = "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d"},
- {file = "pyasn1_modules-0.2.8-py3.2.egg", hash = "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45"},
- {file = "pyasn1_modules-0.2.8-py3.3.egg", hash = "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4"},
- {file = "pyasn1_modules-0.2.8-py3.4.egg", hash = "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811"},
- {file = "pyasn1_modules-0.2.8-py3.5.egg", hash = "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed"},
- {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"},
- {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"},
]
[package.dependencies]
@@ -1181,7 +1121,6 @@ pyasn1 = ">=0.4.6,<0.5.0"
name = "pycparser"
version = "2.20"
description = "C parser in Python"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -1193,7 +1132,6 @@ files = [
name = "pyopenssl"
version = "23.2.0"
description = "Python wrapper module around the OpenSSL library"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -1212,7 +1150,6 @@ test = ["flaky", "pretend", "pytest (>=3.0.1)"]
name = "pyparsing"
version = "2.4.7"
description = "Python parsing module"
-category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
@@ -1224,7 +1161,6 @@ files = [
name = "python-dateutil"
version = "2.8.2"
description = "Extensions to the standard Python datetime module"
-category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
@@ -1239,7 +1175,6 @@ six = ">=1.5"
name = "python-decouple"
version = "3.4"
description = "Strict separation of settings from code."
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1251,7 +1186,6 @@ files = [
name = "python-json-logger"
version = "2.0.2"
description = "A python library adding a json log formatter"
-category = "main"
optional = false
python-versions = ">=3.5"
files = [
@@ -1263,7 +1197,6 @@ files = [
name = "pytz"
version = "2022.1"
description = "World timezone definitions, modern and historical"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1275,7 +1208,6 @@ files = [
name = "requests"
version = "2.31.0"
description = "Python HTTP for Humans."
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1297,7 +1229,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
name = "requests-mock"
version = "1.9.3"
description = "Mock out responses from the requests package"
-category = "dev"
optional = false
python-versions = "*"
files = [
@@ -1317,13 +1248,11 @@ test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.1
name = "requests-oauthlib"
version = "1.3.0"
description = "OAuthlib authentication support for Requests."
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
{file = "requests-oauthlib-1.3.0.tar.gz", hash = "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"},
{file = "requests_oauthlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d"},
- {file = "requests_oauthlib-1.3.0-py3.7.egg", hash = "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"},
]
[package.dependencies]
@@ -1337,7 +1266,6 @@ rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
name = "rsa"
version = "4.7.2"
description = "Pure-Python RSA implementation"
-category = "main"
optional = false
python-versions = ">=3.5, <4"
files = [
@@ -1352,7 +1280,6 @@ pyasn1 = ">=0.1.3"
name = "s3transfer"
version = "0.6.2"
description = "An Amazon S3 Transfer Manager"
-category = "main"
optional = false
python-versions = ">= 3.7"
files = [
@@ -1370,7 +1297,6 @@ crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"]
name = "selenium"
version = "3.141.0"
description = "Python bindings for Selenium"
-category = "dev"
optional = false
python-versions = "*"
files = [
@@ -1385,7 +1311,6 @@ urllib3 = "*"
name = "sentry-sdk"
version = "1.14.0"
description = "Python client for Sentry (https://sentry.io)"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1424,7 +1349,6 @@ tornado = ["tornado (>=5)"]
name = "setuptools"
version = "65.5.1"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1441,7 +1365,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
@@ -1453,7 +1376,6 @@ files = [
name = "sqlparse"
version = "0.4.4"
description = "A non-validating SQL parser."
-category = "main"
optional = false
python-versions = ">=3.5"
files = [
@@ -1470,7 +1392,6 @@ test = ["pytest", "pytest-cov"]
name = "tzdata"
version = "2022.7"
description = "Provider of IANA time zone data"
-category = "main"
optional = false
python-versions = ">=2"
files = [
@@ -1482,7 +1403,6 @@ files = [
name = "uritemplate"
version = "3.0.1"
description = "URI templates"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -1494,7 +1414,6 @@ files = [
name = "urllib3"
version = "1.26.18"
description = "HTTP library with thread-safe connection pooling, file post, and more."
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
files = [
@@ -1511,7 +1430,6 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
name = "uwsgi"
version = "2.0.22"
description = "The uWSGI server"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1522,7 +1440,6 @@ files = [
name = "webencodings"
version = "0.5.1"
description = "Character encoding aliases for legacy web content"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1534,7 +1451,6 @@ files = [
name = "wrapt"
version = "1.15.0"
description = "Module for decorators, wrappers and monkey patching."
-category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
files = [
@@ -1619,7 +1535,6 @@ files = [
name = "zxcvbn"
version = "4.4.28"
description = ""
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1629,4 +1544,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
-content-hash = "603006189586cebf1e868ea4dc8d6bb6f337123c76c9cbf296c680e6e0681b60"
+content-hash = "d49adf3557d5a759172c7f1c5920edcc0cd0dc2759cedfc5f53d04187cb06f21"
diff --git a/pyproject.toml b/pyproject.toml
index 8ef0467986..d151ff94cf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,7 @@ urllib3 = "^1.26.18"
[tool.poetry.dev-dependencies]
coverage = "^7.2.3"
+django-coverage-plugin = "^3.1.0"
django-debug-toolbar = "^3.2.4"
requests = "^2.21.0"
requests-mock = "^1.7.0"
diff --git a/requirements.txt b/requirements.txt
index f759a9472d..877a3bc803 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -177,6 +177,9 @@ django-ckeditor==6.5.1 ; python_version >= "3.9" and python_version < "4.0" \
django-cors-headers==3.14.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:5fbd58a6fb4119d975754b2bc090f35ec160a8373f276612c675b00e8a138739 \
--hash=sha256:684180013cc7277bdd8702b80a3c5a4b3fcae4abb2bf134dceb9f5dfe300228e
+django-coverage-plugin==3.1.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:223d34bf92bebadcb8b7b89932480e41c7bd98b44a8156934488fbe7f4a71f99 \
+ --hash=sha256:eb0ea8ffdb0db11a02994fc99be6500550efb496c350d709f418ff3d8e553a67
django-debug-toolbar==3.2.4 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:644bbd5c428d3283aa9115722471769cac1bec189edf3a0c855fd8ff870375a9 \
--hash=sha256:6b633b6cfee24f232d73569870f19aa86c819d750e7f3e833f2344a9eb4b4409
@@ -401,7 +404,7 @@ pillow==10.0.1 ; python_version >= "3.9" and python_version < "4.0" \
proto-plus==1.22.2 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:0e8cda3d5a634d9895b75c573c9352c16486cb75deb0e078b5fda34db4243165 \
--hash=sha256:de34e52d6c9c6fcd704192f09767cb561bb4ee64e70eede20b0834d841f0be4d
-protobuf==3.20.3 ; python_version < "4.0" and python_version >= "3.9" \
+protobuf==3.20.3 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:03038ac1cfbc41aa21f6afcbcd357281d7521b4157926f30ebecc8d4ea59dcb7 \
--hash=sha256:28545383d61f55b57cf4df63eebd9827754fd2dc25f80c5253f9184235db242c \
--hash=sha256:2e3427429c9cffebf259491be0af70189607f365c2f41c7c3764af6f337105f2 \
@@ -422,7 +425,6 @@ protobuf==3.20.3 ; python_version < "4.0" and python_version >= "3.9" \
--hash=sha256:daa564862dd0d39c00f8086f88700fdbe8bc717e993a21e90711acfed02f2402 \
--hash=sha256:de78575669dddf6099a8a0f46a27e82a1783c557ccc38ee620ed8cc96d3be7d7 \
--hash=sha256:e64857f395505ebf3d2569935506ae0dfc4a15cb80dc25261176c784662cdcc4 \
- --hash=sha256:e67f9af1b607eb3a89aafc9bc68a9d1172aae788b2445cb9fd781bd97531f1f1 \
--hash=sha256:f4bd856d702e5b0d96a00ec6b307b0f51c1982c2bf9c0052cf9019e9a544ba99 \
--hash=sha256:f4c42102bc82a51108e449cbb32b19b180022941c727bac0cfd50170341f16ee
psycopg2==2.9.5 ; python_version >= "3.9" and python_version < "4.0" \
@@ -440,33 +442,11 @@ psycopg2==2.9.5 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:f5b6320dbc3cf6cfb9f25308286f9f7ab464e65cfb105b64cc9c52831748ced2 \
--hash=sha256:fc04dd5189b90d825509caa510f20d1d504761e78b8dfb95a0ede180f71d50e5
pyasn1-modules==0.2.8 ; python_version >= "3.9" and python_version < "4.0" \
- --hash=sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8 \
- --hash=sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199 \
- --hash=sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811 \
- --hash=sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed \
- --hash=sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4 \
--hash=sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e \
- --hash=sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74 \
- --hash=sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb \
- --hash=sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45 \
- --hash=sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd \
- --hash=sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0 \
- --hash=sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d \
- --hash=sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405
+ --hash=sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74
pyasn1==0.4.8 ; python_version >= "3.9" and python_version < "4.0" \
- --hash=sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359 \
- --hash=sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576 \
- --hash=sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf \
- --hash=sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7 \
--hash=sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d \
- --hash=sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00 \
- --hash=sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8 \
- --hash=sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86 \
- --hash=sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12 \
- --hash=sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776 \
- --hash=sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba \
- --hash=sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2 \
- --hash=sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3
+ --hash=sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba
pycparser==2.20 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0 \
--hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705
@@ -493,8 +473,7 @@ requests-mock==1.9.3 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba
requests-oauthlib==1.3.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \
- --hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \
- --hash=sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc
+ --hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a
requests==2.31.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \
--hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1
From b5e970ccd13ca28158bb129c3d34c4a357e42f0f Mon Sep 17 00:00:00 2001
From: Benjamin Moody
Date: Tue, 7 Nov 2023 17:24:00 -0500
Subject: [PATCH 014/181] Enable tracking test coverage for Django templates.
django_coverage_plugin allows measuring test coverage for Django
templates. By enabling this plugin, the coverage reports generated by
GitHub workflows should include template files (.html and others)
alongside the existing .py files.
This appears to require explicitly setting
settings.TEMPLATES[]['OPTIONS']['debug']. I'm not sure if that has
any effect on Django itself, since according to the Django docs, this
should default to the value of settings.DEBUG.
---
physionet-django/.coveragerc | 5 +++++
physionet-django/physionet/settings/base.py | 1 +
2 files changed, 6 insertions(+)
create mode 100644 physionet-django/.coveragerc
diff --git a/physionet-django/.coveragerc b/physionet-django/.coveragerc
new file mode 100644
index 0000000000..4f8710d568
--- /dev/null
+++ b/physionet-django/.coveragerc
@@ -0,0 +1,5 @@
+[run]
+plugins = django_coverage_plugin
+
+[django_coverage_plugin]
+template_extensions = html, txt, json, xml
diff --git a/physionet-django/physionet/settings/base.py b/physionet-django/physionet/settings/base.py
index ca7cb457f7..2134dd24ca 100644
--- a/physionet-django/physionet/settings/base.py
+++ b/physionet-django/physionet/settings/base.py
@@ -122,6 +122,7 @@
'sso.context_processors.sso_enabled',
'physionet.context_processors.cloud_research_environments_config',
],
+ 'debug': DEBUG,
},
},
]
From 35952ff7a802892dcfb57f0d1fab764a6bb9c9f2 Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Thu, 9 Nov 2023 18:09:06 -0500
Subject: [PATCH 015/181] Fixing naming & issues carried over from previous
rebase
---
physionet-django/console/views.py | 8 ++++----
physionet-django/events/views.py | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/physionet-django/console/views.py b/physionet-django/console/views.py
index afcb8df628..3639b97078 100644
--- a/physionet-django/console/views.py
+++ b/physionet-django/console/views.py
@@ -2992,7 +2992,7 @@ def event_management(request, event_slug):
dataset=event_dataset_form.cleaned_data["dataset"],
access_type=event_dataset_form.cleaned_data["access_type"],
is_active=True)
- if len(active_datasets) == 0:
+ if active_datasets.count() == 0:
event_dataset_form.instance.event = selected_event
event_dataset_form.save()
messages.success(
@@ -3018,13 +3018,13 @@ def event_management(request, event_slug):
participants = selected_event.participants.all()
pending_applications = selected_event.applications.filter(
- status__in=[EventApplication.EventApplicationStatus.WAITLISTED]
+ status=EventApplication.EventApplicationStatus.WAITLISTED
)
rejected_applications = selected_event.applications.filter(
- status__in=[EventApplication.EventApplicationStatus.NOT_APPROVED]
+ status=EventApplication.EventApplicationStatus.NOT_APPROVED
)
withdrawn_applications = selected_event.applications.filter(
- status__in=[EventApplication.EventApplicationStatus.WITHDRAWN]
+ status=EventApplication.EventApplicationStatus.WITHDRAWN
)
event_datasets = selected_event.datasets.filter(is_active=True)
diff --git a/physionet-django/events/views.py b/physionet-django/events/views.py
index 6d05a53dd6..3c2f256560 100644
--- a/physionet-django/events/views.py
+++ b/physionet-django/events/views.py
@@ -166,20 +166,20 @@ def event_home(request):
event_details = {}
for event in events:
- all_applications = event.applications.all()
+ applications = event.applications.all()
pending_applications = [
application
- for application in all_applications
+ for application in applications
if application.status == EventApplication.EventApplicationStatus.WAITLISTED
]
rejected_applications = [
application
- for application in all_applications
+ for application in applications
if application.status == EventApplication.EventApplicationStatus.NOT_APPROVED
]
withdrawn_applications = [
application
- for application in all_applications
+ for application in applications
if application.status == EventApplication.EventApplicationStatus.WITHDRAWN
]
From 6abc77377377d9c6317f97a8f38ac7d61f383716 Mon Sep 17 00:00:00 2001
From: Tom Pollard
Date: Sun, 12 Nov 2023 12:49:16 +0100
Subject: [PATCH 016/181] Notify user of secondary email address. Fixes #1291.
The ResetPassword form only works for primary email addresses. This update addresses the issue by informing the user of the primary email address linked to their account.
---
physionet-django/notification/utility.py | 16 ++++++++++++++++
.../user/email/notify_primary_email.html | 8 ++++++++
physionet-django/user/views.py | 18 +++++++++++++++++-
3 files changed, 41 insertions(+), 1 deletion(-)
create mode 100644 physionet-django/user/templates/user/email/notify_primary_email.html
diff --git a/physionet-django/notification/utility.py b/physionet-django/notification/utility.py
index 4960b58e22..ceb641237b 100644
--- a/physionet-django/notification/utility.py
+++ b/physionet-django/notification/utility.py
@@ -1023,3 +1023,19 @@ def notify_event_participant_application(request, user, registered_user, event):
body = loader.render_to_string('events/email/event_registration.html', context)
# Not resend the email if there was an integrity error
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [user.email], fail_silently=False)
+
+
+def notify_primary_email(associated_email):
+ """
+ Inform a user of the primary email address linked to their account.
+ Must only be used when the email address is verified.
+ """
+ if associated_email.is_verified:
+ subject = f"Primary email address on {settings.SITE_NAME}"
+ context = {
+ 'name': associated_email.user.get_full_name(),
+ 'primary_email': associated_email.user.email,
+ 'SITE_NAME': settings.SITE_NAME,
+ }
+ body = loader.render_to_string('user/email/notify_primary_email.html', context)
+ send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [associated_email.email], fail_silently=False)
diff --git a/physionet-django/user/templates/user/email/notify_primary_email.html b/physionet-django/user/templates/user/email/notify_primary_email.html
new file mode 100644
index 0000000000..d34907d84e
--- /dev/null
+++ b/physionet-django/user/templates/user/email/notify_primary_email.html
@@ -0,0 +1,8 @@
+{% load i18n %}{% autoescape off %}{% filter wordwrap:70 %}
+You are receiving this email because you requested a password reset for your account at {{ SITE_NAME }}.
+
+Only your primary email address can be used for resetting your password. Your primary email address is {{ primary_email }}.
+
+Regards
+The {{ SITE_NAME }} Team
+{% endfilter %}{% endautoescape %}
diff --git a/physionet-django/user/views.py b/physionet-django/user/views.py
index 05628b8699..509b18282b 100644
--- a/physionet-django/user/views.py
+++ b/physionet-django/user/views.py
@@ -9,6 +9,7 @@
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
+from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
@@ -29,6 +30,7 @@
credential_application_request,
get_url_prefix,
notify_account_registration,
+ notify_primary_email,
process_credential_complete,
training_application_request,
)
@@ -95,13 +97,27 @@ class LogoutView(auth_views.LogoutView):
pass
+class CustomPasswordResetForm(PasswordResetForm):
+ def clean_email(self):
+ """
+ Override the clean_email method to allow for secondary email checks.
+ """
+ email = self.cleaned_data['email']
+ secondary_email = AssociatedEmail.objects.filter(is_verified=True, is_primary_email=False, email=email).first()
+
+ if secondary_email:
+ notify_primary_email(secondary_email)
+
+ return email
+
+
# Request password reset
class PasswordResetView(auth_views.PasswordResetView):
template_name = 'user/reset_password_request.html'
success_url = reverse_lazy('reset_password_sent')
email_template_name = 'user/email/reset_password_email.html'
extra_email_context = {'SITE_NAME': settings.SITE_NAME}
-
+ form_class = CustomPasswordResetForm
# Page shown after reset email has been sent
class PasswordResetDoneView(auth_views.PasswordResetDoneView):
From 749a6c1bfa972dd29d01492ffdef1426857921d0 Mon Sep 17 00:00:00 2001
From: Tom Pollard
Date: Mon, 13 Nov 2023 12:36:57 +0100
Subject: [PATCH 017/181] Allow submitting author status to be transferred to a
co-author. Ref #2128.
We occasionally ask for submtting author status of a project to be transferred. This change adds a 'transfer project' button to the author page of the project submission system.
---
.../email/notify_submitting_author.html | 11 +++++
physionet-django/notification/utility.py | 19 ++++++++
physionet-django/project/forms.py | 25 ++++++++++
.../static/project/js/transfer-author.js | 22 +++++++++
.../templates/project/project_authors.html | 46 +++++++++++++++++++
physionet-django/project/views.py | 15 +++++-
6 files changed, 137 insertions(+), 1 deletion(-)
create mode 100644 physionet-django/notification/templates/notification/email/notify_submitting_author.html
create mode 100644 physionet-django/project/static/project/js/transfer-author.js
diff --git a/physionet-django/notification/templates/notification/email/notify_submitting_author.html b/physionet-django/notification/templates/notification/email/notify_submitting_author.html
new file mode 100644
index 0000000000..25af6d4a77
--- /dev/null
+++ b/physionet-django/notification/templates/notification/email/notify_submitting_author.html
@@ -0,0 +1,11 @@
+{% load i18n %}{% autoescape off %}{% filter wordwrap:70 %}
+Dear {{ name }},
+
+You have been made submitting author of the project entitled "{{ project.title }}" on {{ SITE_NAME }}.
+
+You can view and edit the project on your project homepage: {{ url_prefix }}{% url "project_home" %}.
+
+{{ signature }}
+
+{{ footer }}
+{% endfilter %}{% endautoescape %}
diff --git a/physionet-django/notification/utility.py b/physionet-django/notification/utility.py
index 4960b58e22..b720443704 100644
--- a/physionet-django/notification/utility.py
+++ b/physionet-django/notification/utility.py
@@ -1023,3 +1023,22 @@ def notify_event_participant_application(request, user, registered_user, event):
body = loader.render_to_string('events/email/event_registration.html', context)
# Not resend the email if there was an integrity error
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [user.email], fail_silently=False)
+
+
+def notify_submitting_author(request, project):
+ """
+ Notify a user that they have been made submitting author for a project.
+ """
+ author = project.authors.get(is_submitting=True)
+ subject = f"{settings.SITE_NAME}: You are now a submitting author"
+ context = {
+ 'name': author.get_full_name(),
+ 'project': project,
+ 'url_prefix': get_url_prefix(request),
+ 'SITE_NAME': settings.SITE_NAME,
+ 'signature': settings.EMAIL_SIGNATURE,
+ 'footer': email_footer()
+ }
+ body = loader.render_to_string('notification/email/notify_submitting_author.html', context)
+ # Not resend the email if there was an integrity error
+ send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [author.user.email], fail_silently=False)
diff --git a/physionet-django/project/forms.py b/physionet-django/project/forms.py
index 7ee1985572..22ee0839a1 100644
--- a/physionet-django/project/forms.py
+++ b/physionet-django/project/forms.py
@@ -73,6 +73,31 @@ def update_corresponder(self):
new_c.save()
+class TransferAuthorForm(forms.Form):
+ """
+ Transfer submitting author.
+ """
+ transfer_author = forms.ModelChoiceField(queryset=None, required=True,
+ widget=forms.Select(attrs={'onchange': 'set_transfer_author()',
+ 'id': 'transfer_author_id'}),
+ empty_label="Select an author")
+
+ def __init__(self, project, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.project = project
+ # Exclude the current submitting author from the queryset
+ authors = project.authors.exclude(is_submitting=True).order_by('display_order')
+ self.fields['transfer_author'].queryset = authors
+
+ def transfer(self):
+ new_author = self.cleaned_data['transfer_author']
+
+ # Assign the new submitting author
+ self.project.authors.update(is_submitting=False)
+ new_author.is_submitting = True
+ new_author.save()
+
+
class ActiveProjectFilesForm(forms.Form):
"""
Inherited form for manipulating project files/directories. Upload
diff --git a/physionet-django/project/static/project/js/transfer-author.js b/physionet-django/project/static/project/js/transfer-author.js
new file mode 100644
index 0000000000..5f72c8df18
--- /dev/null
+++ b/physionet-django/project/static/project/js/transfer-author.js
@@ -0,0 +1,22 @@
+$(document).ready(function() {
+ // Function to update the displayed author name when a new author is selected
+ function set_transfer_author() {
+ var selectedAuthorName = $("#transfer_author_id option:selected").text();
+ $('#project_author').text(selectedAuthorName);
+ }
+
+ // Attach the change event to the author select dropdown to update the name on change
+ $("#transfer_author_id").change(set_transfer_author);
+
+ // Prevent the default form submission and show the confirmation modal
+ $('#authorTransferForm').on('submit', function(e) {
+ e.preventDefault();
+ $('#transfer_author_modal').modal('show');
+ });
+
+ // When the confirmation button is clicked, submit the form
+ $('#confirmAuthorTransfer').on('click', function() {
+ $('#authorTransferForm').off('submit').submit();
+ });
+});
+
diff --git a/physionet-django/project/templates/project/project_authors.html b/physionet-django/project/templates/project/project_authors.html
index 08f368ba47..60ebdf8a80 100644
--- a/physionet-django/project/templates/project/project_authors.html
+++ b/physionet-django/project/templates/project/project_authors.html
@@ -170,6 +170,46 @@
Your Affiliations
Set Affiliations
+
+
+{# Transfer project to a new submitting author #}
+{% if is_submitting %}
+
Submitting Author
+
Only the submitting author of a project is able to edit content.
+ You may transfer the role of submitting author to a co-author.
+ Choose one of the co-authors below to make them the submitting author for this project.
+ Transferring authorship will remove your ability to edit content!
+
+
+
+{% endif %}
+
+
+
+
+
+
+
Transfer Authorship
+
+ ×
+
+
+
+
Please confirm that you would like to assign '' as the new submitting author.
+
{# Disable submission if not currently editable #}
{% if not project.author_editable %}
{% endif %}
+
+{% if is_submitting %}
+
+{% endif %}
+
{% endblock %}
diff --git a/physionet-django/project/views.py b/physionet-django/project/views.py
index a680ab43f5..74e47bb772 100644
--- a/physionet-django/project/views.py
+++ b/physionet-django/project/views.py
@@ -536,8 +536,10 @@ def project_authors(request, project_slug, **kwargs):
inviter=user)
corresponding_author_form = forms.CorrespondingAuthorForm(
project=project)
+ transfer_author_form = forms.TransferAuthorForm(
+ project=project)
else:
- invite_author_form, corresponding_author_form = None, None
+ invite_author_form, corresponding_author_form, transfer_author_form = None, None, None
if author.is_corresponding:
corresponding_email_form = AssociatedEmailChoiceForm(
@@ -591,6 +593,16 @@ def project_authors(request, project_slug, **kwargs):
messages.success(request, 'Your corresponding email has been updated.')
else:
messages.error(request, 'Submission unsuccessful. See form for errors.')
+ elif 'transfer_author' in request.POST and is_submitting:
+ transfer_author_form = forms.TransferAuthorForm(
+ project=project, data=request.POST)
+ if transfer_author_form.is_valid():
+ transfer_author_form.transfer()
+ notification.notify_submitting_author(request, project)
+ messages.success(request, 'The submitting author has been updated.')
+ return redirect('project_authors', project_slug=project.slug)
+ else:
+ messages.error(request, 'Submission unsuccessful. See form for errors.')
authors = project.get_author_info()
invitations = project.authorinvitations.filter(is_active=True)
@@ -601,6 +613,7 @@ def project_authors(request, project_slug, **kwargs):
'invite_author_form':invite_author_form,
'corresponding_author_form':corresponding_author_form,
'corresponding_email_form':corresponding_email_form,
+ 'transfer_author_form': transfer_author_form,
'add_item_url':edit_affiliations_url, 'remove_item_url':edit_affiliations_url,
'is_submitting':is_submitting})
From 34e25d514c3f8bb54c5d502c09911a0b3dcceb63 Mon Sep 17 00:00:00 2001
From: Tom Pollard
Date: Mon, 13 Nov 2023 16:39:10 -0500
Subject: [PATCH 018/181] reformat return statement
---
physionet-django/project/views.py | 26 +++++++++++++++++---------
1 file changed, 17 insertions(+), 9 deletions(-)
diff --git a/physionet-django/project/views.py b/physionet-django/project/views.py
index 74e47bb772..eb11acae14 100644
--- a/physionet-django/project/views.py
+++ b/physionet-django/project/views.py
@@ -607,15 +607,23 @@ def project_authors(request, project_slug, **kwargs):
authors = project.get_author_info()
invitations = project.authorinvitations.filter(is_active=True)
edit_affiliations_url = reverse('edit_affiliation', args=[project.slug])
- return render(request, 'project/project_authors.html', {'project':project,
- 'authors':authors, 'invitations':invitations,
- 'affiliation_formset':affiliation_formset,
- 'invite_author_form':invite_author_form,
- 'corresponding_author_form':corresponding_author_form,
- 'corresponding_email_form':corresponding_email_form,
- 'transfer_author_form': transfer_author_form,
- 'add_item_url':edit_affiliations_url, 'remove_item_url':edit_affiliations_url,
- 'is_submitting':is_submitting})
+ return render(
+ request,
+ "project/project_authors.html",
+ {
+ "project": project,
+ "authors": authors,
+ "invitations": invitations,
+ "affiliation_formset": affiliation_formset,
+ "invite_author_form": invite_author_form,
+ "corresponding_author_form": corresponding_author_form,
+ "corresponding_email_form": corresponding_email_form,
+ "transfer_author_form": transfer_author_form,
+ "add_item_url": edit_affiliations_url,
+ "remove_item_url": edit_affiliations_url,
+ "is_submitting": is_submitting,
+ },
+ )
def edit_content_item(request, project_slug):
From 84141fa376bdce8e67c4d1b593b077fd555c1fe2 Mon Sep 17 00:00:00 2001
From: Tom Pollard
Date: Wed, 15 Nov 2023 13:59:19 -0500
Subject: [PATCH 019/181] Add test case for transferring submitting author.
---
physionet-django/project/test_views.py | 39 ++++++++++++++++++++++++++
1 file changed, 39 insertions(+)
diff --git a/physionet-django/project/test_views.py b/physionet-django/project/test_views.py
index 97393f01ca..83a9f8f0cc 100644
--- a/physionet-django/project/test_views.py
+++ b/physionet-django/project/test_views.py
@@ -553,6 +553,45 @@ def test_content(self):
self.assertFalse(project.is_submittable())
+class TestProjectTransfer(TestCase):
+ """
+ Tests that submitting author status can be transferred to a co-author
+ """
+ AUTHOR_EMAIL = 'rgmark@mit.edu'
+ COAUTHOR_EMAIL = 'aewj@mit.edu'
+ PASSWORD = 'Tester11!'
+ PROJECT_SLUG = 'T108xFtYkRAxiRiuOLEJ'
+
+ def setUp(self):
+ self.client.login(username=self.AUTHOR_EMAIL, password=self.PASSWORD)
+ self.project = ActiveProject.objects.get(slug=self.PROJECT_SLUG)
+ self.submitting_author = self.project.authors.filter(is_submitting=True).first()
+ self.coauthor = self.project.authors.filter(is_submitting=False).first()
+
+ def test_transfer_author(self):
+ """
+ Test that an activate project can be transferred to a co-author.
+ """
+ self.assertEqual(self.submitting_author.user.email, self.AUTHOR_EMAIL)
+ self.assertEqual(self.coauthor.user.email, self.COAUTHOR_EMAIL)
+
+ response = self.client.post(
+ reverse('project_authors', args=(self.project.slug,)),
+ data={
+ 'transfer_author': self.coauthor.user.id,
+ })
+
+ # Check if redirect happens, implying successful transfer
+ self.assertEqual(response.status_code, 302)
+
+ # Fetch the updated project data
+ updated_project = ActiveProject.objects.get(slug=self.PROJECT_SLUG)
+
+ # Verify that the author has been transferred
+ self.assertFalse(updated_project.authors.get(user=self.submitting_author.user).is_submitting)
+ self.assertTrue(updated_project.authors.get(user=self.coauthor.user).is_submitting)
+
+
class TestAccessPublished(TestMixin):
"""
Test that certain views or content in their various states can only
From 71e2fcaa79e919e99b361c3ffd1ac3cbe530d46d Mon Sep 17 00:00:00 2001
From: Benjamin Moody
Date: Fri, 17 Nov 2023 17:03:19 -0500
Subject: [PATCH 020/181] PublishedProject.zip_name: add legacy argument.
We would like to avoid including the project title in zip file names
as well as in the zip file contents, in favor of the project slug,
which is more stable and machine-readable.
Eventually, therefore, the behavior of the zip_name function should be
changed; as a first step in that direction, add an optional argument
so callers can specify whether they want the old or new style.
Existing code continues to use the old style for now.
---
.../project/modelcomponents/publishedproject.py | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/physionet-django/project/modelcomponents/publishedproject.py b/physionet-django/project/modelcomponents/publishedproject.py
index 2b6c91ce34..ea8056ad48 100644
--- a/physionet-django/project/modelcomponents/publishedproject.py
+++ b/physionet-django/project/modelcomponents/publishedproject.py
@@ -123,11 +123,21 @@ def slugged_label(self):
"""
return '-'.join((slugify(self.title), self.version.replace(' ', '-')))
- def zip_name(self, full=False):
+ def zip_name(self, full=False, legacy=True):
"""
Name of the zip file. Either base name or full path name.
+
+ If legacy is true, use the project title to generate the file
+ name (e.g. "demo-ecg-signal-toolbox-10.5.24.zip").
+
+ If false, use the project slug (e.g. "demoecg-10.5.24.zip").
+
+ Eventually the old style will be replaced with the new style.
"""
- name = '{}.zip'.format(self.slugged_label())
+ if legacy:
+ name = '{}.zip'.format(self.slugged_label())
+ else:
+ name = '{}-{}.zip'.format(self.slug, self.version)
if full:
name = os.path.join(self.project_file_root(), name)
return name
From e16b576698158a1736464bfe78bfcac1f7a3f402 Mon Sep 17 00:00:00 2001
From: Tom Pollard
Date: Mon, 20 Nov 2023 15:17:22 -0500
Subject: [PATCH 021/181] Set default ordering of projects. Fixes #2132.
---
physionet-django/project/modelcomponents/activeproject.py | 1 +
physionet-django/project/modelcomponents/publishedproject.py | 1 +
2 files changed, 2 insertions(+)
diff --git a/physionet-django/project/modelcomponents/activeproject.py b/physionet-django/project/modelcomponents/activeproject.py
index 3bdd73e426..ad9be2765b 100644
--- a/physionet-django/project/modelcomponents/activeproject.py
+++ b/physionet-django/project/modelcomponents/activeproject.py
@@ -210,6 +210,7 @@ class Meta:
('can_assign_editor', 'Can assign editor'),
('can_edit_activeprojects', 'Can edit ActiveProjects')
]
+ ordering = ('title', 'creation_datetime')
def storage_used(self):
"""
diff --git a/physionet-django/project/modelcomponents/publishedproject.py b/physionet-django/project/modelcomponents/publishedproject.py
index 2b6c91ce34..c60f557d40 100644
--- a/physionet-django/project/modelcomponents/publishedproject.py
+++ b/physionet-django/project/modelcomponents/publishedproject.py
@@ -77,6 +77,7 @@ class Meta:
('can_view_project_guidelines', 'Can view project guidelines'),
('can_view_stats', 'Can view stats')
]
+ ordering = ('title', 'version_order')
def __str__(self):
return ('{0} v{1}'.format(self.title, self.version))
From c5502021c98257e88644f38722cc26e0eac9e6c1 Mon Sep 17 00:00:00 2001
From: Tom Pollard
Date: Mon, 20 Nov 2023 15:18:13 -0500
Subject: [PATCH 022/181] add migration.
---
...71_alter_activeproject_options_and_more.py | 36 +++++++++++++++++++
1 file changed, 36 insertions(+)
create mode 100644 physionet-django/project/migrations/0071_alter_activeproject_options_and_more.py
diff --git a/physionet-django/project/migrations/0071_alter_activeproject_options_and_more.py b/physionet-django/project/migrations/0071_alter_activeproject_options_and_more.py
new file mode 100644
index 0000000000..2fc107476d
--- /dev/null
+++ b/physionet-django/project/migrations/0071_alter_activeproject_options_and_more.py
@@ -0,0 +1,36 @@
+# Generated by Django 4.1.10 on 2023-11-20 20:17
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("project", "0070_unpublishedproject_version_order_2"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="activeproject",
+ options={
+ "default_permissions": ("change",),
+ "ordering": ("title", "creation_datetime"),
+ "permissions": [
+ ("can_assign_editor", "Can assign editor"),
+ ("can_edit_activeprojects", "Can edit ActiveProjects"),
+ ],
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="publishedproject",
+ options={
+ "default_permissions": ("change",),
+ "ordering": ("title", "version_order"),
+ "permissions": [
+ ("can_edit_featured_content", "Can edit featured content"),
+ ("can_view_access_logs", "Can view access logs"),
+ ("can_view_project_guidelines", "Can view project guidelines"),
+ ("can_view_stats", "Can view stats"),
+ ],
+ },
+ ),
+ ]
From d70a34dd816130f6a5d12ad732373d4f8c6998a1 Mon Sep 17 00:00:00 2001
From: chrystinne
Date: Fri, 22 Sep 2023 12:12:30 -0400
Subject: [PATCH 023/181] Adding a set of functions for managing AWS S3 buckets
and objects through PhysioNet. These functions include creating S3 buckets,
uploading files to buckets, deleting files from buckets, configuring bucket
policies, among other features.
---
physionet-django/console/utility.py | 764 +++++++++++++++++++++++++++-
1 file changed, 761 insertions(+), 3 deletions(-)
diff --git a/physionet-django/console/utility.py b/physionet-django/console/utility.py
index e3f531c90d..84adbfd0aa 100644
--- a/physionet-django/console/utility.py
+++ b/physionet-django/console/utility.py
@@ -13,8 +13,9 @@
from django.contrib.sites.models import Site
from django.conf import settings
from django.urls import reverse
-
-from project.validators import validate_doi
+import boto3
+import os
+import re
import logging
@@ -31,6 +32,764 @@ class DOIExistsError(Exception):
class DOICreationError(Exception):
pass
+# Manage AWS buckets and objects
+
+def create_s3_access_object():
+ """
+ Create and return an AWS S3 client object.
+
+ This function establishes a session with AWS using the specified AWS profile
+ from the 'settings' module and initializes an S3 client object for interacting with
+ Amazon S3 buckets and objects.
+
+ Note:
+ - Ensure that the AWS credentials (Access Key and Secret Key) are properly
+ configured in the AWS CLI profile.
+ - The AWS_PROFILE should be defined in the settings module.
+
+ Returns:
+ botocore.client.S3: An initialized AWS S3 client object.
+ """
+ session = boto3.Session(profile_name=settings.AWS_PROFILE)
+ s3 = session.client('s3')
+ return s3
+
+def get_bucket_name(project):
+ """
+ Determine and return the S3 bucket name associated with a given project.
+
+ This function calculates the S3 bucket name based on the project's slug and version.
+ If the project has a specific access policy defined, the bucket name will be generated
+ accordingly. For projects with an 'OPEN' access policy, the default bucket name is specified
+ in settings.OPEN_ACCESS_DATA_BUCKET_NAME. For other access policies ('RESTRICTED', 'CREDENTIALED', 'CONTRIBUTOR_REVIEW'),
+ the bucket name is constructed using the project's slug and version.
+
+ Args:
+ project (project.models.Project): The project for which to determine the S3 bucket name.
+
+ Returns:
+ str: The S3 bucket name associated with the project.
+
+ Note:
+ - This function does not create or verify the existence of the actual S3 bucket; it only provides
+ the calculated bucket name based on project attributes.
+ """
+ from project.models import AccessPolicy
+
+ bucket_name = project.slug + "-" + project.version
+
+ if project.access_policy == AccessPolicy.OPEN:
+ bucket_name = settings.OPEN_ACCESS_DATA_BUCKET_NAME
+ elif project.access_policy == AccessPolicy.RESTRICTED or project.access_policy == AccessPolicy.CREDENTIALED or project.access_policy == AccessPolicy.CONTRIBUTOR_REVIEW:
+ bucket_name = project.slug + "-" + project.version
+ return bucket_name
+
+def get_bucket_name_and_prefix(project):
+ """
+ Determine the S3 bucket name and optional prefix for a given project.
+
+ This function calculates the S3 bucket name based on the project's attributes. If the project
+ has an 'OPEN' access policy and a non-empty prefix defined, the bucket name includes the prefix.
+ For all other access policies or if no prefix is defined, the bucket name consists of the
+ calculated project-specific name only.
+
+ Args:
+ project (project.models.Project): The project for which to determine the S3 bucket name and prefix.
+
+ Returns:
+ str: The S3 bucket name and an optional prefix associated with the project.
+
+ Note:
+ - This function does not create or verify the existence of the actual S3 bucket; it only provides
+ the calculated bucket name and prefix based on project attributes.
+ """
+ from project.models import AccessPolicy
+
+ prefix = get_prefix_open_project(project)
+ if project.access_policy == AccessPolicy.OPEN and prefix is not None:
+ bucket_name = get_bucket_name(project) + "/" + prefix
+ else:
+ bucket_name = get_bucket_name(project)
+ return bucket_name
+
+def get_all_prefixes(project):
+ """
+ Retrieve a list of all prefixes (directories) for objects within the S3 bucket associated with the given project.
+
+ This function checks if the S3 bucket for the project exists, and if so, it initializes an S3 client,
+ specifies the bucket name, and lists the common prefixes (directories) within the bucket. The retrieved
+ prefixes are returned as a list.
+
+ Args:
+ project (project.models.Project): The project for which to retrieve all prefixes.
+
+ Returns:
+ list: A list of common prefixes (directories) within the S3 bucket.
+
+ Note:
+ - This function does not create or modify objects within the S3 bucket, only retrieves directory-like prefixes.
+ - Make sure that AWS credentials (Access Key and Secret Key) are properly configured for this function to work.
+ - The S3 bucket should exist and be accessible based on the project's configuration.
+ """
+ common_prefixes = []
+ if check_s3_bucket_exists(project):
+ # Initialize the S3 client
+ s3 = create_s3_access_object()
+
+ # Specify the bucket name
+ bucket_name = get_bucket_name(project)
+
+ # List the prefixes (common prefixes or "directories")
+ response = s3.list_objects_v2(
+ Bucket=bucket_name,
+ Delimiter='/'
+ )
+
+ # Extract the prefixes
+ common_prefixes = response.get('CommonPrefixes', [])
+ return common_prefixes
+
+def get_prefix_open_project(project):
+ """
+ Retrieve the prefix (directory) specific to an open project.
+
+ This function checks if the project's access policy is 'OPEN'. If it is, the function constructs
+ the target prefix based on the project's slug and version, and then finds the matching prefix
+ within the S3 bucket's list of prefixes (directories).
+
+ Args:
+ project (project.models.Project): The open project for which to retrieve the specific prefix.
+
+ Returns:
+ str or None: The matching prefix if the project is open; otherwise, None.
+
+ Note:
+ - This function is intended for open projects only; for other access policies, it returns None.
+ - Ensure that the project's S3 bucket exists and is properly configured.
+ """
+ from project.models import AccessPolicy
+
+ if project.access_policy != AccessPolicy.OPEN:
+ return None
+ else:
+ target_prefix = project.slug + "-" + project.version + "/"
+ matching_prefix = find_matching_prefix(get_all_prefixes(project), target_prefix)
+ return matching_prefix
+
+def find_matching_prefix(prefix_list, target_prefix):
+ """
+ Find and return the matching prefix within a list of prefixes for a given target prefix.
+
+ This function iterates through a list of prefixes (commonly found in S3 bucket listings) and compares
+ each prefix to the provided target prefix. If a match is found, the matching prefix is returned after
+ stripping any trailing slashes ('/').
+
+ Args:
+ prefix_list (list): A list of prefixes to search through.
+ target_prefix (str): The prefix to match against the list.
+
+ Returns:
+ str or None: The matching prefix without trailing slashes, or None if no match is found.
+ """
+ for prefix_info in prefix_list:
+ prefix = prefix_info.get('Prefix', '')
+ if prefix == target_prefix:
+ return prefix.rstrip('/')
+ return None
+
+def get_list_prefixes(common_prefixes):
+ """
+ Extract and return a list of prefixes from a given list of common prefixes.
+
+ This function takes a list of common prefixes (often obtained from S3 bucket listings)
+ and extracts the 'Prefix' values from each element, returning them as a list.
+
+ Args:
+ common_prefixes (list): A list of common prefixes, typically from an S3 bucket listing.
+
+ Returns:
+ list: A list of extracted prefixes.
+ """
+ perfixes = [prefix.get('Prefix') for prefix in common_prefixes]
+ return perfixes
+
+def check_s3_bucket_exists(project):
+ """
+ Check the existence of an S3 bucket associated with the given project.
+
+ This function uses the provided project to determine the S3 bucket's name. It then
+ attempts to validate the existence of the bucket by sending a 'HEAD' request to AWS S3.
+ If the bucket exists and is accessible, the function returns True; otherwise, it returns False.
+
+ Args:
+ project (project.models.Project): The project for which to check the S3 bucket's existence.
+
+ Returns:
+ bool: True if the S3 bucket exists and is accessible, False otherwise.
+
+ Note:
+ - Make sure that AWS credentials (Access Key and Secret Key) are properly configured
+ for this function to work.
+ - The S3 bucket should exist and be accessible based on the project's configuration.
+ """
+ s3 = create_s3_access_object()
+ bucket_name = get_bucket_name(project)
+ try:
+ s3.head_bucket(Bucket=bucket_name)
+ return True
+ except:
+ return False
+
+def check_s3_bucket_with_prefix_exists(project):
+ """
+ Check the existence of an S3 bucket, considering the optional prefix for open projects.
+
+ This function assesses whether an S3 bucket associated with the given project exists.
+ If the project has an 'OPEN' access policy and a non-empty prefix defined, the function
+ checks for the existence of the bucket along with the prefix. For all other access policies
+ or if no prefix is defined, it checks only the existence of the bucket.
+
+ Args:
+ project (project.models.Project): The project for which to check the S3 bucket's existence.
+
+ Returns:
+ bool: True if the S3 bucket (and optional prefix) exists and is accessible, False otherwise.
+
+ Note:
+ - Make sure that AWS credentials (Access Key and Secret Key) are properly configured
+ for this function to work.
+ - The S3 bucket should exist and be accessible based on the project's configuration.
+ """
+ from project.models import AccessPolicy
+ prefix = get_prefix_open_project(project)
+ if project.access_policy == AccessPolicy.OPEN and prefix is None:
+ return False
+ else:
+ return check_s3_bucket_exists(project)
+
+def create_s3_bucket(s3, bucket_name):
+ """
+ Create a new Amazon S3 bucket with the specified name.
+
+ This function uses the provided S3 client ('s3') to create a new S3 bucket
+ with the given 'bucket_name'.
+
+ Args:
+ s3 (boto3.client.S3): An initialized AWS S3 client object.
+ bucket_name (str): The desired name for the new S3 bucket.
+
+ Returns:
+ None
+
+ Note:
+ - Ensure that AWS credentials (Access Key and Secret Key) are properly configured
+ for the provided 's3' client to create the bucket.
+ - Bucket names must be globally unique within AWS S3, so choose a unique name.
+ """
+ s3.create_bucket(Bucket=bucket_name)
+
+def send_files_to_s3(folder_path, s3_prefix, bucket_name):
+ """
+ Upload files from a local folder to an AWS S3 bucket with a specified prefix.
+
+ This function walks through the files in the local 'folder_path' and uploads each file
+ to the specified AWS S3 'bucket_name' under the 's3_prefix' directory. It uses an initialized
+ AWS S3 client to perform the file uploads.
+
+ Args:
+ folder_path (str): The local folder containing the files to upload.
+ s3_prefix (str): The prefix (directory) within the S3 bucket where files will be stored.
+ bucket_name (str): The name of the AWS S3 bucket where files will be uploaded.
+
+ Returns:
+ None
+
+ Note:
+ - Ensure that AWS credentials (Access Key and Secret Key) are properly configured for
+ the S3 client used in this function.
+ """
+ for root, _, files in os.walk(folder_path):
+ for file_name in files:
+ local_file_path = os.path.join(root, file_name)
+ s3_key = os.path.join(s3_prefix, os.path.relpath(local_file_path, folder_path))
+ create_s3_access_object().upload_file(
+ Filename=local_file_path,
+ Bucket=bucket_name,
+ Key=s3_key,
+ )
+
+def get_aws_accounts_for_dataset(dataset_name):
+ """
+ Retrieve AWS account IDs associated with a given dataset's authorized users.
+
+ This function identifies AWS account IDs associated with users who are authorized to access
+ the specified project. It searches for AWS account IDs among users with cloud information
+ and permissions to view project files.
+
+ Args:
+ dataset_name (str): The name of the dataset for which to retrieve AWS account IDs.
+
+ Returns:
+ list: A list of AWS account IDs associated with authorized users of the dataset.
+
+ Note:
+ - This function assumes that AWS account IDs are 12-digit numerical values.
+ - Users with the appropriate permissions and AWS account IDs are included in the result list.
+ """
+ from project.models import PublishedProject
+ from user.models import User
+ from project.authorization.access import can_view_project_files
+
+ aws_accounts = []
+
+ published_projects = PublishedProject.objects.all()
+ users_with_awsid = User.objects.filter(cloud_information__aws_id__isnull=False)
+ aws_id_pattern = r"\b\d{12}\b"
+
+ for project in published_projects:
+ project_name = project.slug + "-" + project.version
+ if project_name == dataset_name:
+ for user in users_with_awsid:
+ if can_view_project_files(project, user):
+ if re.search(aws_id_pattern, user.cloud_information.aws_id):
+ aws_accounts.append(user.cloud_information.aws_id)
+ break # Stop iterating once the dataset is found
+
+ return aws_accounts
+
+def get_initial_bucket_policy(bucket_name):
+ """
+ Create an initial bucket policy for an AWS S3 bucket.
+ """
+ bucket_policy ={
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Sid": "DenyListAndGetObject",
+ "Effect": "Deny",
+ "Principal": "*",
+ "Action": [
+ "s3:Get*",
+ "s3:List*"
+ ],
+ "Resource": [
+ f'arn:aws:s3:::{bucket_name}',
+ f'arn:aws:s3:::{bucket_name}/*'
+ ],
+ "Condition": {
+ "StringNotLike": {
+ "aws:PrincipalArn": "arn:aws:iam::724665945834:*"
+ }
+ }
+ }
+ ]
+ }
+ # Convert the policy from JSON dict to string
+ bucket_policy_str = json.dumps(bucket_policy)
+
+ return bucket_policy_str
+
+def create_bucket_policy(bucket_name, aws_ids, public):
+ """
+ Generate an initial AWS S3 bucket policy that restricts access.
+
+ This function creates a default AWS S3 bucket policy with restricted access. The policy denies
+ list and get actions for all principals except for a specified AWS account, which is allowed
+ to access the bucket.
+
+ Args:
+ bucket_name (str): The name of the AWS S3 bucket for which to generate the initial policy.
+
+ Returns:
+ str: A JSON-formatted string representing the initial bucket policy.
+
+ Note:
+ - This initial policy serves as a baseline and can be customized further to meet specific access
+ requirements for the bucket.
+ """
+ user = None
+ principal_value = '*' if public else {'AWS': [f'arn:aws:iam::{aws_id}:root' if user is None or user == '' else f'arn:aws:iam::{aws_id}:user/{user}' for aws_id in aws_ids]}
+
+ bucket_policy = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Sid": "AllowReadOnlyAccess",
+ "Effect": "Allow",
+ 'Principal': principal_value,
+ 'Action': ["s3:Get*", "s3:List*"],
+ 'Resource': [f'arn:aws:s3:::{bucket_name}', f'arn:aws:s3:::{bucket_name}/*']
+ },
+ {
+ "Sid": "RestrictDeleteActions",
+ "Effect": "Deny",
+ "Principal": "*",
+ "Action": "s3:Delete*",
+ "Resource": f'arn:aws:s3:::{bucket_name}/*',
+ "Condition": {
+ "StringNotLike": {
+ "aws:PrincipalArn": "arn:aws:iam::724665945834:*"
+ }
+ }
+ }
+ ]
+ }
+
+ # Convert the policy from JSON dict to string
+ bucket_policy_str = json.dumps(bucket_policy)
+
+ return bucket_policy_str
+
+def set_bucket_policy(bucket_name, bucket_policy):
+ """
+ Apply a custom AWS S3 bucket policy to a specified bucket.
+
+ This function utilizes an AWS S3 client to set the custom 'bucket_policy' for the
+ specified 'bucket_name', effectively configuring access controls and permissions for
+ objects within the bucket.
+
+ Args:
+ bucket_name (str): The name of the AWS S3 bucket for which to set the custom policy.
+ bucket_policy (str): A JSON-formatted string representing the custom bucket policy.
+
+ Returns:
+ None
+
+ Note:
+ - Ensure that AWS credentials (Access Key and Secret Key) are properly configured for
+ the S3 client used in this function.
+ - The 'bucket_policy' should be a valid JSON string adhering to AWS S3 policy syntax.
+ """
+ s3 = create_s3_access_object()
+ s3.put_bucket_policy(Bucket=bucket_name, Policy=bucket_policy)
+
+def put_public_access_block(client, bucket_name, configuration):
+ """
+ Configure Amazon S3 Block Public Access settings for a bucket.
+
+ Amazon S3 Block Public Access provides settings for buckets to control and manage public access
+ to Amazon S3 resources. By default, new buckets do not allow public access. This function
+ sets the specified configuration for the given bucket to allow or restrict public access.
+
+ Args:
+ client (boto3.client.S3): An initialized AWS S3 client object.
+ bucket_name (str): The name of the S3 bucket to configure.
+ configuration (bool): The desired configuration for public access. Set to 'True' to allow
+ public access or 'False' to restrict public access.
+
+ Returns:
+ None
+
+ Note:
+ - To create a bucket that allows public access, you must set 'configuration' to 'True' for all four settings:
+ 'BlockPublicAcls', 'IgnorePublicAcls', 'BlockPublicPolicy', and 'RestrictPublicBuckets'.
+ - Ensure that AWS credentials (Access Key and Secret Key) are properly configured for the provided 'client'.
+ """
+ client.put_public_access_block(
+ Bucket=bucket_name,
+ PublicAccessBlockConfiguration={
+ 'BlockPublicAcls': configuration,
+ 'IgnorePublicAcls': configuration,
+ 'BlockPublicPolicy': configuration,
+ 'RestrictPublicBuckets': configuration
+ }
+ )
+
+def update_bucket_policy(project, bucket_name):
+ """
+ Update the AWS S3 bucket's access policy based on the project's access policy.
+
+ This function manages the AWS S3 bucket's access policy for the specified 'bucket_name'
+ based on the 'project' object's access policy. It performs the following actions:
+ - For projects with an 'OPEN' access policy, it allows public access by removing any
+ public access blocks and applying a policy allowing public reads.
+ - For projects with other access policies (e.g., 'CREDENTIALED', 'RESTRICTED', 'CONTRIBUTOR_REVIEW'),
+ it sets a policy that limits access to specific AWS accounts belonging to users authorized to access
+ the specified project.
+
+ Args:
+ project (project.models.Project): The project for which to update the bucket policy.
+ bucket_name (str): The name of the AWS S3 bucket associated with the project.
+
+ Returns:
+ None
+
+ Note:
+ - Ensure that AWS credentials (Access Key and Secret Key) are properly configured for
+ the S3 client used in this function.
+ """
+ from project.models import AccessPolicy
+
+ project_name = project.slug + "-" + project.version
+ aws_ids = get_aws_accounts_for_dataset(project_name)
+ if project.access_policy == AccessPolicy.OPEN:
+ put_public_access_block(create_s3_access_object(), bucket_name, False)
+ bucket_policy = create_bucket_policy(bucket_name, aws_ids, True)
+ elif project.access_policy == AccessPolicy.CREDENTIALED or project.access_policy == AccessPolicy.RESTRICTED or project.access_policy == AccessPolicy.CONTRIBUTOR_REVIEW:
+ if aws_ids == []:
+ bucket_policy = get_initial_bucket_policy(bucket_name)
+ else:
+ bucket_policy = create_bucket_policy(bucket_name, aws_ids, False)
+
+ set_bucket_policy(bucket_name, bucket_policy)
+
+def upload_project_to_S3(project):
+ """
+ Upload project-related files to an AWS S3 bucket associated with the project.
+
+ This function orchestrates the process of uploading project files to an AWS S3 bucket.
+ It performs the following steps:
+ 1. Creates the S3 bucket if it doesn't exist.
+ 2. Updates the bucket's access policy to align with the project's requirements.
+ 3. Uploads project files from the local directory to the S3 bucket.
+
+ Args:
+ project (project.models.Project): The project for which to upload files to S3.
+
+ Returns:
+ None
+
+ Note:
+ - Ensure that AWS credentials (Access Key and Secret Key) are properly configured for
+ the S3 client used in this function.
+ - The 'project_name' variable is created by concatenating the project's slug and version.
+ - The 's3_prefix' is set only for projects with an 'OPEN' access policy, providing
+ an optional prefix within the S3 bucket.
+ """
+ from project.models import AccessPolicy
+ bucket_name = get_bucket_name(project)
+ # create bucket if it does not exist
+ create_s3_bucket(create_s3_access_object(), bucket_name)
+
+ # update bucket's policy for projects
+ update_bucket_policy(project, bucket_name)
+
+ # upload files to bucket
+ folder_path = project.project_file_root() # Folder containing files to upload
+ project_name = project.slug + "-" + project.version
+
+ # set the prefix only for the projects in the open data bucket
+ s3_prefix = ""
+ if project.access_policy == AccessPolicy.OPEN:
+ s3_prefix = f"{project_name}/"
+ send_files_to_s3(folder_path, s3_prefix, bucket_name)
+
+def upload_list_of_projects(projects):
+ """
+ Bulk upload a list of projects to AWS S3.
+
+ This function iterates through the provided list of 'projects' and uploads each project's files
+ to an AWS S3 bucket. It delegates the file upload process to the 'upload_project_to_S3' function.
+
+ Args:
+ projects (list of project.models.Project): A list of projects to upload to AWS S3.
+
+ Returns:
+ None
+
+ Note:
+ - Ensure that AWS credentials (Access Key and Secret Key) are properly configured for
+ the S3 client used in the 'upload_project_to_S3' function.
+ """
+ for project in projects:
+ upload_project_to_S3(project)
+
+def upload_all_projects():
+ """
+ Bulk upload all published projects to AWS S3.
+
+ This function retrieves all published projects from the database and uploads the files
+ associated with each project to an AWS S3 bucket. It leverages the 'upload_project_to_S3'
+ function for the file upload process.
+
+ Args:
+ None
+
+ Returns:
+ None
+
+ Note:
+ - Ensure that AWS credentials (Access Key and Secret Key) are properly configured for
+ the S3 client used in the 'upload_project_to_S3' function.
+ """
+ from project.models import PublishedProject
+ published_projects = PublishedProject.objects.all()
+ for project in published_projects:
+ upload_project_to_S3(project)
+
+def empty_s3_bucket(bucket):
+ """
+ Delete all objects (files) within an AWS S3 bucket.
+
+ This function iterates through all objects within the specified 'bucket' and deletes each object,
+ effectively emptying the bucket of its contents.
+
+ Args:
+ bucket (boto3.resources.factory.s3.Bucket): The AWS S3 bucket to empty.
+
+ Returns:
+ None
+
+ Note:
+ - Ensure that AWS credentials (Access Key and Secret Key) with sufficient permissions
+ are properly configured for the S3 client used in this function.
+ """
+ for key in bucket.objects.all():
+ key.delete()
+
+def empty_project_from_S3(project):
+ """
+ Empty an AWS S3 bucket associated with a project.
+
+ This function removes all files within the AWS S3 bucket associated with the specified 'project',
+ effectively emptying the bucket. It leverages the 'empty_s3_bucket' function for this purpose.
+
+ Args:
+ project (project.models.Project): The project for which to empty the associated AWS S3 bucket.
+
+ Returns:
+ None
+
+ Note:
+ - The function ensures that the AWS S3 bucket remains intact but contains no files after execution.
+ - Ensure that AWS credentials (Access Key and Secret Key) with sufficient permissions
+ are properly configured for the S3 client used in this function.
+ """
+ boto3.setup_default_session(settings.AWS_PROFILE)
+ s3 = boto3.resource('s3')
+ bucket_name = get_bucket_name(project)
+ bucket = s3.Bucket(bucket_name)
+ empty_s3_bucket(bucket)
+
+def empty_list_of_projects(projects):
+ """
+ Bulk empty AWS S3 buckets associated with a list of projects.
+
+ This function takes a list of 'projects' and empties the AWS S3 buckets associated with each project,
+ effectively removing all files within them. It delegates the emptying process to the 'empty_project_from_S3'
+ function.
+
+ Args:
+ projects (list of project.models.Project): A list of projects for which to empty the associated AWS S3 buckets.
+
+ Returns:
+ None
+
+ Note:
+ - The function ensures that AWS S3 buckets remain intact but contain no files after execution.
+ - Ensure that AWS credentials (Access Key and Secret Key) with sufficient permissions
+ are properly configured for the S3 client used in this function.
+ """
+ for project in projects:
+ empty_project_from_S3(project)
+
+def empty_all_projects_from_S3():
+ """
+ Bulk empty all AWS S3 buckets associated with published projects.
+
+ This function iterates through all published projects in the database and empties the AWS S3
+ buckets associated with each project, effectively removing all files within them. It leverages
+ the 'empty_project_from_S3' function for this purpose.
+
+ Args:
+ None
+
+ Returns:
+ None
+
+ Note:
+ - The function ensures that AWS S3 buckets remain intact but contain no files after execution.
+ - Ensure that AWS credentials (Access Key and Secret Key) with sufficient permissions
+ are properly configured for the S3 client used in this function.
+ """
+ from project.models import PublishedProject
+ # Retrieve all published projects from the database
+ published_projects = PublishedProject.objects.all()
+ # Empty the AWS S3 buckets associated with each published project
+ for project in published_projects:
+ empty_project_from_S3(project)
+
+def delete_project_from_S3(project):
+ """
+ Delete a project's files and associated AWS S3 bucket.
+
+ This function cleans up and removes all files associated with the specified 'project' from an
+ AWS S3 bucket. It then deletes the empty S3 bucket itself.
+
+ Args:
+ project (project.models.Project): The project for which to delete files and the associated bucket.
+
+ Returns:
+ None
+
+ Note:
+ - The function leverages the 'delete_s3_bucket' method to perform the deletion.
+ - Ensure that AWS credentials (Access Key and Secret Key) with sufficient permissions
+ are properly configured for the S3 client used in this function.
+ """
+ boto3.setup_default_session(settings.AWS_PROFILE)
+ s3 = boto3.resource('s3')
+ bucket_name = get_bucket_name(project)
+ bucket = s3.Bucket(bucket_name)
+ # Empty the specified AWS S3 'bucket' by deleting all objects (files) within it
+ empty_s3_bucket(bucket)
+ # Delete the empty bucket itself
+ bucket.delete()
+
+def delete_list_of_projects_from_s3(projects):
+ """"
+ Bulk delete a list of projects from AWS S3.
+
+ This function deletes a list of projects, including their files and associated AWS S3 buckets.
+ If the list includes open projects, the 'delete_project_from_S3' function will be called only once
+ for the first open project encountered.
+
+ Args:
+ projects (list of project.models.Project): A list of projects to delete from AWS S3.
+
+ Returns:
+ None
+
+ Note:
+ - The function leverages the 'delete_project_from_S3' method to perform project deletions.
+ - Ensure that AWS credentials (Access Key and Secret Key) with sufficient permissions
+ are properly configured for the S3 client used in this function.
+ """
+ for project in projects:
+ delete_project_from_S3(project)
+ if project.access_policy == AccessPolicy.OPEN:
+ break
+
+def delete_all_projects_from_S3():
+ """
+ Bulk delete all published projects from AWS S3.
+
+ This function deletes all published projects, including their files and associated AWS S3 buckets.
+ It distinguishes between open and non-open projects, calling the 'delete_project_from_S3' function
+ accordingly. Non-open projects, such as 'RESTRICTED', 'CREDENTIALED', or 'CONTRIBUTOR_REVIEW', are
+ deleted individually. Open projects are stored in the same S3 bucket, so only one delete operation
+ is needed, which is performed by passing the first open project.
+
+ Args:
+ None
+
+ Returns:
+ None
+
+ Note:
+ - The function leverages the 'delete_project_from_S3' method to perform project deletions.
+ - Ensure that AWS credentials (Access Key and Secret Key) with sufficient permissions
+ are properly configured for the S3 client used in this function.
+ """
+ from project.models import PublishedProject, AccessPolicy
+ not_open_published_projects = PublishedProject.objects.filter(access_policy=AccessPolicy.RESTRICTED) | PublishedProject.objects.filter(access_policy=AccessPolicy.CREDENTIALED) | PublishedProject.objects.filter(access_policy=AccessPolicy.CONTRIBUTOR_REVIEW)
+ for project in not_open_published_projects:
+ delete_project_from_S3(project)
+ # since all open projects are stored in the same bucket, we only need to delete the bucket once
+ # by passing the first open project
+ delete_project_from_S3(PublishedProject.objects.filter(access_policy=AccessPolicy.OPEN)[0])
+
+# Manage GCP buckets
def check_bucket_exists(project, version):
"""
@@ -42,7 +801,6 @@ def check_bucket_exists(project, version):
return True
return False
-
def create_bucket(project, version, title, protected=True):
"""
Create a bucket with either public or private permissions.
From 21f5cd42b9f396e9835ced4f250775aff4e3a15a Mon Sep 17 00:00:00 2001
From: chrystinne
Date: Fri, 22 Sep 2023 12:14:24 -0400
Subject: [PATCH 024/181] Added AWS credentials configuration for S3 storage
access and defined the bucket name for the S3 bucket containing open access
data.
---
physionet-django/physionet/settings/base.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/physionet-django/physionet/settings/base.py b/physionet-django/physionet/settings/base.py
index 2134dd24ca..a2f7b2a0a9 100644
--- a/physionet-django/physionet/settings/base.py
+++ b/physionet-django/physionet/settings/base.py
@@ -226,6 +226,12 @@
# Alternate hostname to be used in example download commands
BULK_DOWNLOAD_HOSTNAME = config('BULK_DOWNLOAD_HOSTNAME', default=None)
+# AWS credentials to access to S3 storage
+AWS_PROFILE = config('AWS_PROFILE', default=False)
+
+# Bucket name for the S3 bucket containing the open access data
+OPEN_ACCESS_DATA_BUCKET_NAME = config('OPEN_ACCESS_DATA_BUCKET_NAME', default=False)
+
# Header tags for the AWS lambda function that grants access to S3 storage
AWS_HEADER_KEY = config('AWS_KEY', default=False)
AWS_HEADER_VALUE = config('AWS_VALUE', default=False)
From 986ec6eb138983cf296e973d3feca19fbb801ce8 Mon Sep 17 00:00:00 2001
From: chrystinne
Date: Fri, 22 Sep 2023 12:21:29 -0400
Subject: [PATCH 025/181] Added functions for AWS S3 integration, including
uploading project files to S3, updating the S3 bucket's access policy, and
managing AWS S3 buckets for projects published on PhysioNet.
---
physionet-django/console/views.py | 91 ++++++++++++++++++++++++++++++-
1 file changed, 89 insertions(+), 2 deletions(-)
diff --git a/physionet-django/console/views.py b/physionet-django/console/views.py
index 3639b97078..7e9a15059e 100644
--- a/physionet-django/console/views.py
+++ b/physionet-django/console/views.py
@@ -75,7 +75,7 @@
from physionet.enums import LogCategory
from console import forms, utility, services
from console.forms import ProjectFilterForm, UserFilterForm
-
+from console import views
LOGGER = logging.getLogger(__name__)
@@ -763,6 +763,60 @@ def send_files_to_gcp(pid):
project.gcp.sent_zip = True
project.gcp.save()
+@associated_task(PublishedProject, 'pid', read_only=True)
+@background()
+def send_files_to_aws(pid):
+ """
+ Upload project files to AWS S3 buckets.
+
+ This function retrieves the project identified by 'pid' and uploads its files to
+ the appropriate AWS S3 bucket. It utilizes the 'upload_project_to_S3' function
+ from the 'utility' module.
+
+ Args:
+ pid (int): The unique identifier (ID) of the project to upload.
+
+ Returns:
+ None
+
+ Note:
+ - Verify that AWS credentials and configurations are correctly set up for the S3 client.
+ """
+ project = PublishedProject.objects.get(id=pid)
+ utility.upload_project_to_S3(project)
+
+
+@associated_task(PublishedProject, 'pid', read_only=True)
+@background()
+def update_aws_bucket_policy(pid):
+ """
+ Update the AWS S3 bucket's access policy based on the project's access policy.
+
+ This function determines the access policy of the project identified by 'pid' and updates
+ the AWS S3 bucket's access policy accordingly. It checks if the bucket exists, retrieves
+ its name, and uses the 'utility.update_bucket_policy' function for the update.
+
+ Args:
+ pid (int): The unique identifier (ID) of the project for which to update the bucket policy.
+
+ Returns:
+ bool: True if the bucket policy was updated successfully, False otherwise.
+
+ Note:
+ - Verify that AWS credentials and configurations are correctly set up for the S3 client.
+ - The 'updated_policy' variable indicates whether the policy was updated successfully.
+ """
+ from console import utility
+ updated_policy = False
+ project = PublishedProject.objects.get(id=pid)
+ exists = utility.check_s3_bucket_exists(project)
+ if exists:
+ bucket_name = utility.get_bucket_name(project)
+ utility.update_bucket_policy(project, bucket_name)
+ updated_policy = True
+ else:
+ updated_policy = False
+ return updated_policy
@permission_required('project.change_publishedproject', raise_exception=True)
def manage_doi_request(request, project):
@@ -818,6 +872,7 @@ def manage_published_project(request, project_slug, version):
- Deprecate files
- Create GCP bucket and send files
"""
+ from console.utility import get_bucket_name_and_prefix, check_s3_bucket_with_prefix_exists
try:
project = PublishedProject.objects.get(slug=project_slug, version=version)
except PublishedProject.DoesNotExist:
@@ -829,6 +884,9 @@ def manage_published_project(request, project_slug, version):
topic_form.set_initial()
deprecate_form = None if project.deprecated_files else forms.DeprecateFilesForm()
has_credentials = bool(settings.GOOGLE_APPLICATION_CREDENTIALS)
+ has_aws_credentials = bool(settings.AWS_PROFILE)
+ s3_bucket_exists = check_s3_bucket_with_prefix_exists(project)
+ s3_bucket_name = get_bucket_name_and_prefix(project)
data_access_form = forms.DataAccessForm(project=project)
contact_form = forms.PublishedProjectContactForm(project=project,
instance=project.contact)
@@ -892,6 +950,11 @@ def manage_published_project(request, project_slug, version):
messages.error(request, 'Project has tasks pending.')
else:
gcp_bucket_management(request, project, user)
+ elif 'aws-bucket' in request.POST and has_aws_credentials:
+ if any(get_associated_tasks(project, read_only=False)):
+ messages.error(request, 'Project has tasks pending.')
+ else:
+ aws_bucket_management(request, project, user)
elif 'platform' in request.POST:
data_access_form = forms.DataAccessForm(project=project, data=request.POST)
if data_access_form.is_valid():
@@ -953,6 +1016,9 @@ def manage_published_project(request, project_slug, version):
'topic_form': topic_form,
'deprecate_form': deprecate_form,
'has_credentials': has_credentials,
+ 'has_aws_credentials': has_aws_credentials,
+ 'aws_bucket_exists': s3_bucket_exists,
+ 's3_bucket_name': s3_bucket_name,
'data_access_form': data_access_form,
'data_access': data_access,
'rw_tasks': rw_tasks,
@@ -993,7 +1059,7 @@ def gcp_bucket_management(request, project, user):
LOGGER.info("The bucket {0} already exists, skipping bucket and \
group creation".format(bucket_name))
else:
- utility.create_bucket(project.slug, project.version, project.title, is_private)
+ utility.create_s3_bucket(project.slug, project.version, project.title, is_private)
messages.success(request, "The GCP bucket for project {0} was \
successfully created.".format(project))
GCP.objects.create(project=project, bucket_name=bucket_name,
@@ -1013,6 +1079,27 @@ def gcp_bucket_management(request, project, user):
send_files_to_gcp(project.id, verbose_name='GCP - {}'.format(project), creator=user)
+@permission_required('project.change_publishedproject', raise_exception=True)
+def aws_bucket_management(request, project, user):
+ """
+ Manage AWS S3 bucket for a project.
+
+ This function is responsible for creating an AWS S3 bucket and sending the project's files
+ to that bucket. It orchestrates the necessary steps to set up the bucket and populate it
+ with the project's data.
+
+ Args:
+ project (PublishedProject): The project for which to create and populate the AWS S3 bucket.
+
+ Returns:
+ None
+
+ Note:
+ - Ensure that AWS credentials and configurations are correctly set up for the S3 client.
+ """
+ send_files_to_aws(project.id)
+
+
@permission_required('project.change_archivedproject', raise_exception=True)
def archived_submissions(request):
"""
From 063708885e31ba08f0ea589d1b2b606e32949ee9 Mon Sep 17 00:00:00 2001
From: chrystinne
Date: Fri, 22 Sep 2023 12:26:54 -0400
Subject: [PATCH 026/181] Updating the AWS S3 bucket policy by adding
authorized users after user agreement submission.
---
physionet-django/project/views.py | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/physionet-django/project/views.py b/physionet-django/project/views.py
index eb11acae14..2073b0fc1f 100644
--- a/physionet-django/project/views.py
+++ b/physionet-django/project/views.py
@@ -1803,6 +1803,7 @@ def published_project(request, project_slug, version, subdir=''):
"""
Displays a published project
"""
+ from console.utility import get_bucket_name_and_prefix, check_s3_bucket_with_prefix_exists
try:
project = PublishedProject.objects.get(slug=project_slug,
version=version)
@@ -1827,6 +1828,8 @@ def published_project(request, project_slug, version, subdir=''):
platform_citations = project.get_platform_citation()
show_platform_wide_citation = any(platform_citations.values())
main_platform_citation = next((item for item in platform_citations.values() if item is not None), '')
+ s3_bucket_exists = check_s3_bucket_with_prefix_exists(project)
+ s3_bucket_name = get_bucket_name_and_prefix(project)
# Anonymous access authentication
an_url = request.get_signed_cookie('anonymousaccess', None, max_age=60 * 60)
@@ -1884,6 +1887,8 @@ def published_project(request, project_slug, version, subdir=''):
'platform_citations': platform_citations,
'is_lightwave_supported': project.files.is_lightwave_supported(),
'is_wget_supported': project.files.is_wget_supported(),
+ 'aws_bucket_exists': s3_bucket_exists,
+ 's3_bucket_name': s3_bucket_name,
'show_platform_wide_citation': show_platform_wide_citation,
'main_platform_citation': main_platform_citation,
}
@@ -1940,6 +1945,8 @@ def sign_dua(request, project_slug, version):
Page to sign the dua for a protected project.
Both restricted and credentialed policies.
"""
+ from console import utility
+ from console import views
user = request.user
project = PublishedProject.objects.filter(slug=project_slug, version=version)
if project:
@@ -1964,6 +1971,7 @@ def sign_dua(request, project_slug, version):
if request.method == 'POST' and 'agree' in request.POST:
DUASignature.objects.create(user=user, project=project)
+ views.update_aws_bucket_policy(project.id)
return render(request, 'project/sign_dua_complete.html', {
'project':project})
From 3e28fcf4f6bc690168d84b27354323de952f8e27 Mon Sep 17 00:00:00 2001
From: chrystinne
Date: Fri, 22 Sep 2023 12:30:20 -0400
Subject: [PATCH 027/181] Added AWS functionality that includes checking for
AWS credentials, creating an AWS bucket if it doesn't exist, and enabling
users to send or resend project files to AWS as needed.
---
.../console/manage_published_project.html | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/physionet-django/console/templates/console/manage_published_project.html b/physionet-django/console/templates/console/manage_published_project.html
index de933752d6..4b2ad7d2ad 100644
--- a/physionet-django/console/templates/console/manage_published_project.html
+++ b/physionet-django/console/templates/console/manage_published_project.html
@@ -408,6 +408,28 @@
Google Cloud
If this message is here for a long time, check the Django "process_tasks"
{% endif %}
+
+
AWS
+ {% if not has_aws_credentials %}
+
You are missing the AWS credentials.
+ {% elif not aws_bucket_exists %}
+
Create a bucket on AWS to store the files associated with this project.
+
+ {% elif aws_bucket_exists %}
+
The files have been sent to AWS. The bucket name is: {{s3_bucket_name}}.
+
+ {% endif %}
+
From 535ee8cdd6c0b0d29bc9fbc799091659a07355e5 Mon Sep 17 00:00:00 2001
From: chrystinne
Date: Fri, 22 Sep 2023 12:32:16 -0400
Subject: [PATCH 028/181] Added the option to download files using AWS command
line tools when an AWS bucket exists for a given project.
---
.../project/templates/project/published_project.html | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/physionet-django/project/templates/project/published_project.html b/physionet-django/project/templates/project/published_project.html
index 3ec8ddccd7..c77fb816ab 100644
--- a/physionet-django/project/templates/project/published_project.html
+++ b/physionet-django/project/templates/project/published_project.html
@@ -430,6 +430,12 @@
We've been working very hard on the PhysioNet rebuild. The staging site is available at https://staging.physionet.org/. We hope to release the beta version of the new site by November 2018.
We are considering hosting challenges on Kaggle. This would free up users to use their own computing resources and choice of programming systems. Because submissions will not have their code run automatically, we will be vigilant in ensuring the quality of submissions. Unusable code will see their submissions penalized and/or disqualified.
",
"publish_datetime": "2018-08-17T19:53:33.258Z",
- "url": "https://www.kaggle.com/"
+ "url": "https://www.kaggle.com/",
+ "slug": "new-challenges"
}
}
]
diff --git a/physionet-django/notification/models.py b/physionet-django/notification/models.py
index af876f87c1..1cae1c82f1 100644
--- a/physionet-django/notification/models.py
+++ b/physionet-django/notification/models.py
@@ -7,6 +7,7 @@
class News(models.Model):
"""
+ Model to record news and announcements.
"""
title = models.CharField(max_length=100)
content = SafeHTMLField()
@@ -16,6 +17,7 @@ class News(models.Model):
on_delete=models.SET_NULL, related_name='news')
guid = models.CharField(max_length=64, default=uuid.uuid4)
front_page_banner = models.BooleanField(default=False)
+ slug = models.SlugField(max_length=100, unique=True)
class Meta:
default_permissions = ('change',)
diff --git a/physionet-django/notification/templates/notification/news.html b/physionet-django/notification/templates/notification/news.html
index d857b6f3e7..bdb26431b3 100644
--- a/physionet-django/notification/templates/notification/news.html
+++ b/physionet-django/notification/templates/notification/news.html
@@ -40,7 +40,7 @@
From c6b353be71889bf2c23d2b95d52345a10255314d Mon Sep 17 00:00:00 2001
From: Benjamin Moody
Date: Thu, 30 Nov 2023 16:37:58 -0500
Subject: [PATCH 085/181] console_navbar: fix lots of broken and inconsistent
IDs.
HTML IDs must be unique. Furthermore, to avoid confusion or possible
clashes, all of the IDs used in the navbar should begin with "nav_".
All of the #sectionComponents are renamed to #nav_section_components
(corresponding to #nav_section_dropdown), for consistency.
The "credentialing" section is renamed to "identity". The "usage"
section is renamed to "stats".
For consistency, ensure all of the page links have an ID. All of
these links are renamed to #nav_viewname (corresponding to
{% url 'viewname' %}.)
For those links that kludgily point to a URL with a mandatory
parameter, use #nav_viewname__arg.
---
.../templates/console/console_navbar.html | 118 +++++++++---------
1 file changed, 59 insertions(+), 59 deletions(-)
diff --git a/physionet-django/console/templates/console/console_navbar.html b/physionet-django/console/templates/console/console_navbar.html
index d93b7abfa9..e92140d97e 100644
--- a/physionet-django/console/templates/console/console_navbar.html
+++ b/physionet-django/console/templates/console/console_navbar.html
@@ -33,18 +33,18 @@
{% if perms.project.change_publishedproject %}
{% if project_info_nav or submitted_projects_nav or unsubmitted_projects_nav or published_projects_nav or archived_projects_nav %}
-
+
{% else %}
-
+
{% endif %}
Projects
{% if project_info_nav or submitted_projects_nav or unsubmitted_projects_nav or published_projects_nav or archived_projects_nav %}
-
+
{% else %}
-
+
{% endif %}
Unsubmitted
@@ -75,27 +75,27 @@
{% if perms.user.change_credentialapplication %}
{% if credentials_nav or past_credentials_nav or known_ref_nav or processing_credentials_nav %}
-
+
{% else %}
-
+
{% endif %}
Identity check
{% if credentials_nav or past_credentials_nav or known_ref_nav or processing_credentials_nav %}
-
Active
@@ -142,37 +142,37 @@
{% if perms.project.add_dua or perms.project.add_codeofconduct or perms.project.add_license or perms.events.add_eventagreement %}
{% if license_nav or dua_nav or code_of_conduct_nav %}
-
+
{% else %}
-
+
{% endif %}
Legal
{% if license_nav or dua_nav or code_of_conduct_nav or event_agreement_nav %}
-
@@ -183,31 +183,31 @@
{% if perms.project.can_view_access_logs %}
{% if project_access_logs_nav or user_access_logs_nav or gcp_logs_nav or access_requests_nav %}
-
+
{% else %}
-
+
{% endif %}
Logs
{% if project_access_logs_nav or user_access_logs_nav or gcp_logs_nav or access_requests_nav %}
-
@@ -349,7 +349,7 @@
{% if perms.notification.change_news %}
-
+ News
From 36703e78e7996dd363c3424a5e6036e6694822d3 Mon Sep 17 00:00:00 2001
From: Benjamin Moody
Date: Thu, 30 Nov 2023 16:43:23 -0500
Subject: [PATCH 086/181] console_navbar: add nav-link class where missing.
For consistency, ensure that all of the page links have the same class.
---
.../console/templates/console/console_navbar.html | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/physionet-django/console/templates/console/console_navbar.html b/physionet-django/console/templates/console/console_navbar.html
index e92140d97e..114bb8c583 100644
--- a/physionet-django/console/templates/console/console_navbar.html
+++ b/physionet-django/console/templates/console/console_navbar.html
@@ -89,13 +89,13 @@
{% endif %}
-
{% if perms.project.change_publishedproject %}
{% if project_info_nav or submitted_projects_nav or unsubmitted_projects_nav or published_projects_nav or archived_projects_nav %}
@@ -40,7 +37,6 @@
Projects
-
{% if project_info_nav or submitted_projects_nav or unsubmitted_projects_nav or published_projects_nav or archived_projects_nav %}
{% else %}
@@ -61,7 +57,7 @@
{% endif %}
-
+
{% if perms.project.change_storagerequest %}
{% endif %}
-
{% if perms.user.change_credentialapplication %}
{% if credentials_nav or past_credentials_nav or known_ref_nav or processing_credentials_nav %}
@@ -82,7 +77,6 @@
Identity check
-
{% if credentials_nav or past_credentials_nav or known_ref_nav or processing_credentials_nav %}
{% if events_nav %}
@@ -122,7 +114,6 @@
Events
-
{% if nav_event_active or nav_event_archive %}
{% else %}
@@ -138,7 +129,6 @@
{% endif %}
-
{% if perms.project.add_dua or perms.project.add_codeofconduct or perms.project.add_license or perms.events.add_eventagreement %}
{% if license_nav or dua_nav or code_of_conduct_nav %}
@@ -149,7 +139,6 @@
Legal
-
{% if license_nav or dua_nav or code_of_conduct_nav or event_agreement_nav %}
{% else %}
@@ -179,7 +168,6 @@
{% endif %}
-
{% if perms.project.can_view_access_logs %}
{% if project_access_logs_nav or user_access_logs_nav or gcp_logs_nav or access_requests_nav %}
@@ -190,7 +178,6 @@
Logs
-
{% if project_access_logs_nav or user_access_logs_nav or gcp_logs_nav or access_requests_nav %}
{% else %}
@@ -213,9 +200,7 @@
{% endif %}
-
-
{% if perms.user.view_user %}
{% if user_nav %}
@@ -226,7 +211,6 @@
Users
-
{% if user_nav %}
{% else %}
@@ -251,7 +235,6 @@
{% endif %}
-
{% if perms.project.can_edit_featured_content %}
{% endif %}
From 43790af17dc9c8e740dfc97f9ccec563d12c710d Mon Sep 17 00:00:00 2001
From: Benjamin Moody
Date: Thu, 30 Nov 2023 17:30:26 -0500
Subject: [PATCH 091/181] console_navbar: replace hardcoded links with a
generic template.
The new class NavMenu is used to construct the navigation menu links.
Previously, console_navbar.html determined which links and submenus
should be displayed by hardcoding the permissions, which needed to
match the corresponding permissions for the view. This is now done
automatically, by relying on the @console_permission_required
decorator.
Previously, in order to determine which link should be highlighted as
"active" and which submenu should be visible by default, there was a
separate "something_nav" parameter for every menu item, and the
corresponding view needed to set the correct parameter by hand. This
is now done automatically by comparing the request path to the link
path.
(This relies on the assumption that if page X is a logical
sub-component of page Y, then URL Y is a prefix of URL X. That
assumption might be wrong in some cases - in which case we should
change the URLs to paths that make more sense, and perhaps add
redirections if needed.
Note in particular that every page in the console probably ought to
have exactly one of the menu-item URLs as a prefix.)
---
physionet-django/console/navbar.py | 214 +++++++++++
.../templates/console/console_navbar.html | 337 ++----------------
.../templatetags/console_templatetags.py | 23 ++
3 files changed, 258 insertions(+), 316 deletions(-)
create mode 100644 physionet-django/console/navbar.py
diff --git a/physionet-django/console/navbar.py b/physionet-django/console/navbar.py
new file mode 100644
index 0000000000..25c7968423
--- /dev/null
+++ b/physionet-django/console/navbar.py
@@ -0,0 +1,214 @@
+import functools
+
+from django.conf import settings
+from django.urls import get_resolver, reverse
+from django.utils.translation import gettext_lazy as _
+
+from physionet.settings.base import StorageTypes
+
+
+class NavLink:
+ """
+ A link to be displayed in the navigation menu.
+
+ The exact URL of the link is determined by reversing the view
+ name.
+
+ The use of view_args is deprecated, and provided for compatibility
+ with existing views that require a static URL argument. Don't use
+ view_args for newly added items.
+
+ The link will only be displayed in the menu if the logged-in user
+ has permission to access that URL. This means that the
+ corresponding view function must be decorated with the
+ console_permission_required decorator.
+
+ The link will appear as "active" if the request URL matches the
+ link URL or a descendant.
+ """
+ def __init__(self, title, view_name, icon=None, *,
+ enabled=True, view_args=()):
+ self.title = title
+ self.icon = icon
+ self.enabled = enabled
+ self.view_name = view_name
+ self.view_args = view_args
+ if self.view_args:
+ self.name = self.view_name + '__' + '_'.join(self.view_args)
+ else:
+ self.name = self.view_name
+
+ @functools.cached_property
+ def url(self):
+ return reverse(self.view_name, args=self.view_args)
+
+ @functools.cached_property
+ def required_permission(self):
+ view = get_resolver().resolve(self.url).func
+ return view.required_permission
+
+ def is_visible(self, request):
+ return self.enabled and request.user.has_perm(self.required_permission)
+
+ def is_active(self, request):
+ return request.path.startswith(self.url)
+
+
+class NavHomeLink(NavLink):
+ """
+ Variant of NavLink that does not include sub-pages.
+ """
+ def is_active(self, request):
+ return request.path == self.url
+
+
+class NavSubmenu:
+ """
+ A collection of links to be displayed as a submenu.
+ """
+ def __init__(self, title, name, icon=None, items=[]):
+ self.title = title
+ self.name = name
+ self.icon = icon
+ self.items = items
+
+
+class NavMenu:
+ """
+ A collection of links and submenus for navigation.
+ """
+ def __init__(self, items):
+ self.items = items
+
+ def get_menu_items(self, request):
+ """
+ Return the navigation menu items for an HTTP request.
+
+ This returns a list of dictionaries, each of which represents
+ either a page link or a submenu.
+
+ For a page link, the dictionary contains:
+ - 'title': human readable title
+ - 'name': unique name (corresponding to the view name)
+ - 'icon': icon name
+ - 'url': page URL
+ - 'active': true if this page is currently active
+
+ For a submenu, the dictionary contains:
+ - 'title': human readable title
+ - 'name': unique name
+ - 'icon': icon name
+ - 'subitems': list of page links
+ - 'active': true if this submenu is currently active
+ """
+ visible_items = []
+
+ for item in self.items:
+ if isinstance(item, NavSubmenu):
+ subitems = item.items
+ elif isinstance(item, NavLink):
+ subitems = [item]
+ else:
+ raise TypeError(item)
+
+ visible_subitems = []
+ active = False
+ for subitem in subitems:
+ if subitem.is_visible(request):
+ subitem_active = subitem.is_active(request)
+ active = active or subitem_active
+ visible_subitems.append({
+ 'title': subitem.title,
+ 'name': subitem.name,
+ 'icon': subitem.icon,
+ 'url': subitem.url,
+ 'active': subitem_active,
+ })
+
+ if visible_subitems:
+ if isinstance(item, NavSubmenu):
+ visible_items.append({
+ 'title': item.title,
+ 'name': item.name,
+ 'icon': item.icon,
+ 'subitems': visible_subitems,
+ 'active': active,
+ })
+ else:
+ visible_items += visible_subitems
+
+ return visible_items
+
+
+CONSOLE_NAV_MENU = NavMenu([
+ NavHomeLink(_('Home'), 'console_home', 'book-open'),
+
+ NavLink(_('Editor Home'), 'editor_home', 'book-open'),
+
+ NavSubmenu(_('Projects'), 'projects', 'clipboard-list', [
+ NavLink(_('Unsubmitted'), 'unsubmitted_projects'),
+ NavLink(_('Submitted'), 'submitted_projects'),
+ NavLink(_('Published'), 'published_projects'),
+ NavLink(_('Archived'), 'archived_submissions'),
+ ]),
+
+ NavLink(_('Storage'), 'storage_requests', 'cube'),
+
+ NavSubmenu(_('Identity check'), 'identity', 'hand-paper', [
+ NavLink(_('Processing'), 'credential_processing'),
+ NavLink(_('All Applications'), 'credential_applications',
+ view_args=['successful']),
+ NavLink(_('Known References'), 'known_references'),
+ ]),
+
+ NavLink(_('Training check'), 'training_list', 'school',
+ view_args=['review']),
+
+ NavSubmenu(_('Events'), 'events', 'clipboard-list', [
+ NavLink(_('Active'), 'event_active'),
+ NavLink(_('Archived'), 'event_archive'),
+ ]),
+
+ NavSubmenu(_('Legal'), 'legal', 'handshake', [
+ NavLink(_('Licenses'), 'license_list'),
+ NavLink(_('DUAs'), 'dua_list'),
+ NavLink(_('Code of Conduct'), 'code_of_conduct_list'),
+ NavLink(_('Event Agreements'), 'event_agreement_list'),
+ ]),
+
+ NavSubmenu(_('Logs'), 'logs', 'fingerprint', [
+ NavLink(_('Project Logs'), 'project_access_logs'),
+ NavLink(_('Access Requests'), 'project_access_requests_list'),
+ NavLink(_('User Logs'), 'user_access_logs'),
+ NavLink(_('GCP Logs'), 'gcp_signed_urls_logs',
+ enabled=(settings.STORAGE_TYPE == StorageTypes.GCP)),
+ ]),
+
+ NavSubmenu(_('Users'), 'users', 'user-check', [
+ NavLink(_('Active Users'), 'users', view_args=['active']),
+ NavLink(_('Inactive Users'), 'users', view_args=['inactive']),
+ NavLink(_('All Users'), 'users', view_args=['all']),
+ NavLink(_('User Groups'), 'user_groups'),
+ NavLink(_('Administrators'), 'users', view_args=['admin']),
+ ]),
+
+ NavLink(_('Featured Content'), 'featured_content', 'star'),
+
+ NavSubmenu(_('Guidelines'), 'guidelines', 'book', [
+ NavLink(_('Project review'), 'guidelines_review'),
+ ]),
+
+ NavSubmenu(_('Usage Stats'), 'stats', 'chart-area', [
+ NavLink(_('Editorial'), 'editorial_stats'),
+ NavLink(_('Credentialing'), 'credentialing_stats'),
+ NavLink(_('Submissions'), 'submission_stats'),
+ ]),
+
+ NavSubmenu(_('Pages'), 'pages', 'window-maximize', [
+ NavLink(_('Static Pages'), 'static_pages'),
+ NavLink(_('Frontpage Buttons'), 'frontpage_buttons'),
+ NavLink(_('Redirects'), 'redirects'),
+ ]),
+
+ NavLink(_('News'), 'news_console', 'newspaper'),
+])
diff --git a/physionet-django/console/templates/console/console_navbar.html b/physionet-django/console/templates/console/console_navbar.html
index e3573ffa9c..69285cae1e 100644
--- a/physionet-django/console/templates/console/console_navbar.html
+++ b/physionet-django/console/templates/console/console_navbar.html
@@ -1,4 +1,5 @@
{% load static %}
+{% load console_templatetags %}
",
+ "order": 0
+ }
+ ],
+ "quizzes": [
+ {
+ "question": "What is the correct answer(choice2)?",
+ "order": 1,
+ "choices": [
+ {
+ "body": "I am a choice1",
+ "is_correct": false
+ },
+ {
+ "body": "I am a choice2",
+ "is_correct": true
+ },
+ {
+ "body": "I am a choice3",
+ "is_correct": false
+ },
+ {
+ "body": "I am a choice4",
+ "is_correct": false
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/physionet-django/training/fixtures/demo-training.json b/physionet-django/training/fixtures/demo-training.json
new file mode 100644
index 0000000000..85233b4e10
--- /dev/null
+++ b/physionet-django/training/fixtures/demo-training.json
@@ -0,0 +1,435 @@
+[
+ {
+ "model": "training.course",
+ "pk": 1,
+ "fields": {
+ "training_type": 2,
+ "version": 1.0
+ }
+ },
+ {
+ "model": "training.module",
+ "pk": 1,
+ "fields": {
+ "name": "The Americas",
+ "description": "Learn about the Americas, including North and South America, Asia and Africa.",
+ "course": 1,
+ "order": 1
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 1,
+ "fields": {
+ "module": 1,
+ "body": "
The Americas are comprised of two continents: North America and South America. These continents are connected by a narrow strip of land called the Isthmus of Panama. Together, North and South America span from the Arctic Circle in the north to Cape Horn in the south.
\n\n
North America is home to three large countries: Canada, the United States, and Mexico. It also includes several smaller countries in Central America and the Caribbean. The continent is known for its diverse landscapes, ranging from frozen tundras in the north to tropical rainforests in the south.
\n\n
South America is made up of twelve countries, including Brazil, Argentina, and Colombia. It is known for its stunning natural beauty, including the Amazon rainforest and the Andes mountain range. The continent also has a rich cultural heritage, with vibrant cities and ancient ruins.
(Copied content)The Americas are comprised of two continents: North America and South America. These continents are connected by a narrow strip of land called the Isthmus of Panama. Together, North and South America span from the Arctic Circle in the north to Cape Horn in the south.
\n\n
North America is home to three large countries: Canada, the United States, and Mexico. It also includes several smaller countries in Central America and the Caribbean. The continent is known for its diverse landscapes, ranging from frozen tundras in the north to tropical rainforests in the south.
\n\n
South America is made up of twelve countries, including Brazil, Argentina, and Colombia. It is known for its stunning natural beauty, including the Amazon rainforest and the Andes mountain range. The continent also has a rich cultural heritage, with vibrant cities and ancient ruins.
The Americas, also known as America, are lands of the Western Hemisphere composed of numerous entities and regions variably defined by geography, politics, and culture1. The Americas are recognized in the English-speaking world to include two separate continents: North America and South America1.
\n\n
The Americas have more than 1.014 billion inhabitants and boast an area of over 16.43 million square miles2. The Americas comprise 35 countries, including some of the world’s largest countries as well as several dependent territories2.
North America is home to three large countries: Canada, the United States, and Mexico. It also includes several smaller countries in Central America and the Caribbean. The continent is known for its diverse landscapes, ranging from frozen tundras in the north to tropical rainforests in the south.
South America is made up of twelve countries, including Brazil, Argentina, and Colombia. It is known for its stunning natural beauty, including the Amazon rainforest and the Andes mountain range. The continent also has a rich cultural heritage, with vibrant cities and ancient ruins.
As our course on World 101: Introduction to Continents and Countries comes to an end, I would like to take this opportunity to thank each and every one of you for your participation and engagement throughout the course.
\n\n
It has been a pleasure to share this journey with you and I hope that the knowledge and insights gained during our time together will serve you well in your future endeavors.
\n\n
Thank you for making this course a success. I wish you all the best in your future studies and pursuits.
\n",
+ "order": 11
+ }
+ },
+ {
+ "model": "training.module",
+ "pk": 2,
+ "fields": {
+ "course": 1,
+ "name": "Wondering about Europe?",
+ "description": "Europe is a continent located in the Northern Hemisphere. It is bordered by the Arctic Ocean to the north, the Atlantic Ocean to the west, and the Mediterranean Sea to the south. It is also connected to Asia by the Ural Mountains and the Caspian Sea.",
+ "order": 2
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 8,
+ "fields": {
+ "module": 2,
+ "body": "
Europe is a continent located in the Northern Hemisphere. It is bordered by the Arctic Ocean to the north, the Atlantic Ocean to the west, and the Mediterranean Sea to the south. It is also connected to Asia by the Ural Mountains and the Caspian Sea.
\n\n
Europe is home to a diverse range of cultures, landscapes, and languages. It is also the second-smallest continent in the world, with a total area of 3.93 million square miles (10.2 million square kilometers).
Europe is made up of 50 countries. The largest by area is Russia, while the smallest is Monaco. The continent is also home to many important cities, including London, Paris, and Istanbul.
Europe is the second most populous continent in the world, with a population of 741 million. It is also home to many of the world’s most well-known historical sites, including Stonehenge, the Parthenon, and the Colosseum.
Europe is also known for its diverse landscapes. It is home to the highest mountain in the world, Mount Everest, as well as the lowest point on land, the Dead Sea. It is also home to the world’s largest ice sheet, the Greenland ice sheet.
The continent is also home to some of the world’s largest lakes, including Lake Superior and Lake Baikal. It is also home to the world’s largest river, the Amazon.
Europe is also home to many of the world’s most well-known cities. London, Paris, and Rome are some of the most famous cities in the world. They are also home to many of the world’s most famous landmarks, including the Eiffel Tower, the Colosseum, and the Parthenon.
Europe is also home to many of the world’s most famous museums. The Louvre in Paris is one of the most famous museums in the world. It is also home to many of the world’s most famous works of art, including the Mona Lisa and the Venus de Milo.
Europe is also home to many of the world’s most famous landmarks. The Eiffel Tower in Paris is one of the most famous landmarks in the world. It is also home to many of the world’s most famous works of art, including the Mona Lisa and the Venus de Milo.
Join us for an exciting journey around the globe as we explore the continents and countries that make up our world. In this training, you’ll learn about the geography, culture, and history of different regions and gain a deeper understanding of our interconnected world.
\n\n
What You Will Learn:
\n\n
\n\t
The names and locations of the seven continents
\n\t
Key countries and their capitals on each continent
\n\t
Basic geographical features and landmarks
\n\t
Cultural and historical highlights of different regions
\n
\n\n
Prerequisites:
\n\n
\n\t
No prior knowledge is required
\n\t
An interest in geography and world cultures is recommended
\n
\n\n
Don’t miss this opportunity to expand your horizons and discover the fascinating world we live in. Our experienced instructors will guide you through this engaging training, providing insights and knowledge along the way. Sign up now to reserve your spot!
\n\n
Contact: For more information or to register for this training, please contact us at training@discoveringtheworld.com or call us at 555-1234.
-
+
\ No newline at end of file
diff --git a/physionet-django/console/templates/console/guidelines_course.html b/physionet-django/console/templates/console/guidelines_course.html
new file mode 100644
index 0000000000..94df083bd0
--- /dev/null
+++ b/physionet-django/console/templates/console/guidelines_course.html
@@ -0,0 +1,161 @@
+{% extends "console/base_console.html" %}
+{% load static %}
+{% block title %}Guidelines for Courses{% endblock %}
+
+{% block content %}
+
+
+ Guidelines for creating and updating courses courses
+
+
+
+
To create a new course or update a course, you will need to organize all the course content in a json file
+ and upload via
+ Courses page.
+
Here is the course schema with typing info that you can
+ follow to create a new course or update a course. Similarly, here is an example json file to create a new course or to update a course
Course Information: Fill out all fields in the JSON file with the appropriate information
+ about your course, including:
+
+
+
The name of the course
+
A description of the course in html
+
The valid duration of the course
+
The version of the course
+
+ Note the importance of versioning: It's essential to keep track of the version number
+ of your course.
+ Please make sure the version number is unique for each course update. Even minor changes, such as a spelling
+ correction,
+ require an update to the version number in the new JSON file (refer to the create and update JSON examples
+ above).
+ Please use the Semantic Versioning system (Major.Minor) for version numbers.
+ If you update a course's major version (e.g., from 1.5 to 2.0), all existing training certificates provided
+ to
+ users will expire after a month. To regain access to resources, users must retake the new version of the
+ training.
+
+
+
+
Modules: A course may have one or more modules that contain the contents,quizzes for each
+ section of the course. Each module should include:
+
+
+
A name for the module
+
A description of the module in html
+
An order for the module
+
One or more content items in the module
+
One or more quizzes in the module
+
+
+
+
Content: A module may have one or more content. Each content should include:
+
+
A body for the content in html
+
An order for the content
+
+
+
+
Quizzes: A module may have one or more quiz. Each Quiz should include:
+
+
A question for the quiz
+
An order for the quiz
+
One or more choices for the quiz, including the correct answer
+ Order: Note that the order is used to determine which content or quiz comes first,
+ so please
+ make sure you order the content and quiz properly. For example, here the user will see the content
+ and quiz in the following order
+ content1, quiz 1, content 2, quiz 2.
+
- Guidelines for creating and updating courses courses
+ Guidelines for creating and updating courses
To create a new course or update a course, you will need to organize all the course content in a json file
- and upload via
+ and upload via the
Courses page.
{% endblock %}
diff --git a/physionet-django/training/views.py b/physionet-django/training/views.py
index 9b34ab0a0a..8b0463d900 100644
--- a/physionet-django/training/views.py
+++ b/physionet-django/training/views.py
@@ -91,7 +91,6 @@ def download_course(request, pk, version):
serializer = TrainingTypeSerializer(training_type)
response_data = serializer.data
- response_data['courses'] = list(filter(lambda x: x['version'] == version, response_data['courses']))
response = JsonResponse(response_data, safe=False, json_dumps_params={'indent': 2})
response['Content-Disposition'] = f'attachment; filename={training_type.name}--version-{version}.json'
return response
From eaf2d592a64431656e5c3bdbd6ab0b8d02b53b17 Mon Sep 17 00:00:00 2001
From: Rutvik Solanki
Date: Mon, 28 Aug 2023 11:59:23 -0400
Subject: [PATCH 125/181] Merged Migrations due to multiple migrations error
---
.../user/migrations/0057_merge_20230828_1158.py | 14 ++++++++++++++
1 file changed, 14 insertions(+)
create mode 100644 physionet-django/user/migrations/0057_merge_20230828_1158.py
diff --git a/physionet-django/user/migrations/0057_merge_20230828_1158.py b/physionet-django/user/migrations/0057_merge_20230828_1158.py
new file mode 100644
index 0000000000..c1d6e5ce87
--- /dev/null
+++ b/physionet-django/user/migrations/0057_merge_20230828_1158.py
@@ -0,0 +1,14 @@
+# Generated by Django 4.1.10 on 2023-08-28 15:58
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('user', '0056_alter_trainingtype_required_field'),
+ ('user', '0056_remove_credentialreview_appears_correct_and_more'),
+ ]
+
+ operations = [
+ ]
From 25f6f632caddf2b17c98cf2b3883ba005e777ec7 Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Wed, 13 Sep 2023 11:26:32 -0400
Subject: [PATCH 126/181] Rebasing onto Physionet dev
---
physionet-django/user/views.py | 7 -------
1 file changed, 7 deletions(-)
diff --git a/physionet-django/user/views.py b/physionet-django/user/views.py
index 5ec18e113d..4607ce8c1c 100644
--- a/physionet-django/user/views.py
+++ b/physionet-django/user/views.py
@@ -834,15 +834,8 @@ def edit_certification(request):
return render(
request,
-<<<<<<< HEAD
"user/edit_certification.html",
{"training_by_status": training_by_status},
-=======
- 'user/edit_training.html',
- {'training_form': training_form,
- 'training_by_status': training_by_status,
- 'ticket_system_url': ticket_system_url},
->>>>>>> 40c25082 (removing accidental changes)
)
From 0faeb9081db0053dc4eb802121adea2de7baf44f Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Wed, 13 Sep 2023 12:46:35 -0400
Subject: [PATCH 127/181] Fixed Version Comparision
---
physionet-django/training/views.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/physionet-django/training/views.py b/physionet-django/training/views.py
index 8b0463d900..1c6a9069d6 100644
--- a/physionet-django/training/views.py
+++ b/physionet-django/training/views.py
@@ -46,11 +46,13 @@ def courses(request):
# Checking if the Training type with the same version already exists
existing_course = Course.objects.filter(training_type=training_type)
if existing_course.exists():
+ # checking if the version number is valid
if not all(map(lambda x: x.isdigit() or x == '.', str(file_data['courses'][0]['version']))):
messages.error(request, 'Version number is not valid.')
- elif file_data['courses'][0]['version'] <= float(existing_course.order_by(
- '-version').first().version): # Version Number is greater than the latest version
- messages.error(request, 'Version number should be greater than the latest version.')
+ # checking if the version number is greater than the existing version
+ elif float(file_data['courses'][0]['version']
+ ) <= float(existing_course.order_by('-version').first().version):
+ messages.error(request, 'Version number should be greater than the existing version.')
else: # Checks passed and moving to saving the course
serializer = TrainingTypeSerializer(training_type, data=file_data, partial=True)
if serializer.is_valid(raise_exception=False):
From fbd14c4ac30caf0244886a1c7788c0fbb4df4c01 Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Sun, 5 Nov 2023 21:05:40 -0500
Subject: [PATCH 128/181] addressed docstring comment
---
physionet-django/training/models.py | 94 +++++++++++++++++++
.../email/training_expiry_notification.html | 6 +-
2 files changed, 98 insertions(+), 2 deletions(-)
diff --git a/physionet-django/training/models.py b/physionet-django/training/models.py
index aadc83968c..aa543ceefd 100644
--- a/physionet-django/training/models.py
+++ b/physionet-django/training/models.py
@@ -9,6 +9,14 @@
class Course(models.Model):
+ """
+ A model representing a course for a specific training type.
+
+ Attributes:
+ training_type (ForeignKey): The training type associated with the course.
+ version (CharField): The version of the course.
+ """
+
training_type = models.ForeignKey(
"user.TrainingType", on_delete=models.CASCADE, related_name="courses"
)
@@ -52,6 +60,16 @@ def __str__(self):
class Module(models.Model):
+ """
+ A module is a unit of teaching within a course, typically covering a single topic or area of knowledge.
+
+ Attributes:
+ name (str): The name of the module.
+ course (Course): The course to which the module belongs.
+ order (int): The order in which the module appears within the course.
+ description (SafeHTML): The description of the module, in SafeHTML format.
+ """
+
name = models.CharField(max_length=100)
course = models.ForeignKey(
"training.Course", on_delete=models.CASCADE, related_name="modules"
@@ -67,6 +85,12 @@ def __str__(self):
class Quiz(models.Model):
+ """
+ A model representing a quiz within a training module.
+
+ Each quiz has a question, belongs to a specific module, and has a designated order within that module.
+ """
+
question = SafeHTMLField()
module = models.ForeignKey(
"training.Module", on_delete=models.CASCADE, related_name="quizzes"
@@ -75,6 +99,15 @@ class Quiz(models.Model):
class ContentBlock(models.Model):
+ """
+ A model representing a block of content within a training module.
+
+ Attributes:
+ module (ForeignKey): The module to which this content block belongs.
+ body (SafeHTMLField): The HTML content of the block.
+ order (PositiveIntegerField): The order in which this block should be displayed within the module.
+ """
+
module = models.ForeignKey(
"training.Module", on_delete=models.CASCADE, related_name="contents"
)
@@ -83,6 +116,12 @@ class ContentBlock(models.Model):
class QuizChoice(models.Model):
+ """
+ A quiz choice is a collection of choices, which is a collection of several types of
+ content. A quiz choice is associated with a quiz, and an order number.
+ The order number is used to track the order of the quiz choices in a quiz.
+ """
+
quiz = models.ForeignKey(
"training.Quiz", on_delete=models.CASCADE, related_name="choices"
)
@@ -91,6 +130,21 @@ class QuizChoice(models.Model):
class CourseProgress(models.Model):
+ """
+ Model representing the progress of a user in a course.
+
+ Fields:
+ - user: ForeignKey to User model
+ - course: ForeignKey to Course model
+ - status: CharField with choices of "In Progress" and "Completed"
+ - started_at: DateTimeField that is automatically added on creation
+ - completed_at: DateTimeField that is nullable and blankable
+
+ Methods:
+ - __str__: Returns a string representation of the CourseProgress object
+ - get_next_module: Returns the next module that the user should be working on
+ """
+
class Status(models.TextChoices):
IN_PROGRESS = "IP", "In Progress"
COMPLETED = "C", "Completed"
@@ -141,6 +195,22 @@ def get_next_module(self):
class ModuleProgress(models.Model):
+ """
+ Model representing the progress of a user in a module.
+
+ Fields:
+ - course_progress: ForeignKey to CourseProgress model
+ - module: ForeignKey to Module model
+ - status: CharField with choices of "In Progress" and "Completed"
+ - last_completed_order: PositiveIntegerField with default value of 0
+ - started_at: DateTimeField that is nullable and blankable
+ - updated_at: DateTimeField that is automatically updated on save
+
+ Methods:
+ - __str__: Returns a string representation of the ModuleProgress object
+
+ """
+
class Status(models.TextChoices):
IN_PROGRESS = "IP", "In Progress"
COMPLETED = "C", "Completed"
@@ -163,6 +233,18 @@ def __str__(self):
class CompletedContent(models.Model):
+ """
+ Model representing a completed content block.
+
+ Fields:
+ - module_progress: ForeignKey to ModuleProgress model
+ - content: ForeignKey to ContentBlock model
+ - completed_at: DateTimeField that is nullable and blankable
+
+ Methods:
+ - __str__: Returns a string representation of the CompletedContent object
+ """
+
module_progress = models.ForeignKey(
"training.ModuleProgress",
on_delete=models.CASCADE,
@@ -176,6 +258,18 @@ def __str__(self):
class CompletedQuiz(models.Model):
+ """
+ Model representing a completed quiz.
+
+ Fields:
+ - module_progress: ForeignKey to ModuleProgress model
+ - quiz: ForeignKey to Quiz model
+ - completed_at: DateTimeField that is nullable and blankable
+
+ Methods:
+ - __str__: Returns a string representation of the CompletedQuiz object
+ """
+
module_progress = models.ForeignKey(
"training.ModuleProgress",
on_delete=models.CASCADE,
diff --git a/physionet-django/training/templates/training/email/training_expiry_notification.html b/physionet-django/training/templates/training/email/training_expiry_notification.html
index c01f26a47a..4ff1d7054d 100644
--- a/physionet-django/training/templates/training/email/training_expiry_notification.html
+++ b/physionet-django/training/templates/training/email/training_expiry_notification.html
@@ -1,9 +1,11 @@
{% load i18n %}{% autoescape off %}{% filter wordwrap:70 %}
Dear {{ name }},
-Your training {{ training }} on {{ domain }} will be expiring in {{ expiry }} days. To retain the access it provides, kindly login to your account to retake it.
+Your {{ training }} training on {{ domain }} will be expiring in {{ expiry }} days. After the expiry date, you may lose
+access to certain resources. To retain access, please submit a new training certificate via the training page in your
+user profile.
Regards
The {{ SITE_NAME }} Team
-{% endfilter %}{% endautoescape %}
+{% endfilter %}{% endautoescape %}
\ No newline at end of file
From b332cad899dcbdd4f7bdf6fda76adbeaf07aa774 Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Wed, 8 Nov 2023 11:27:24 -0500
Subject: [PATCH 129/181] implemented validate_version and simplified code for
major version change
---
physionet-django/training/views.py | 38 +++++++++++++++++++++++++-----
1 file changed, 32 insertions(+), 6 deletions(-)
diff --git a/physionet-django/training/views.py b/physionet-django/training/views.py
index 1c6a9069d6..c503baa8ad 100644
--- a/physionet-django/training/views.py
+++ b/physionet-django/training/views.py
@@ -1,3 +1,4 @@
+from hmac import new
import json
import operator
from itertools import chain
@@ -10,6 +11,7 @@
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.crypto import get_random_string
+from project.validators import validate_version
from rest_framework.parsers import JSONParser
@@ -21,8 +23,26 @@
from training.serializers import TrainingTypeSerializer
+# Utility Functions
+def is_major_change(version1, version2):
+ """
+ This function takes two version numbers as input parameters,
+ and returns True if the first digit of the version number is changed,
+ else returns False.
+ """
+ version1_first_digit = int(str(version1).split('.', maxsplit=1)[0])
+ version2_first_digit = int(str(version2).split('.', maxsplit=1)[0])
+ if version1_first_digit != version2_first_digit:
+ return True
+ return False
+
+
@permission_required('training.change_course', raise_exception=True)
def courses(request):
+ """
+ View function for managing courses.
+ Allows creation and updating of courses for a given training type.
+ """
if request.POST:
if request.POST.get('training_id') != "-1":
@@ -46,19 +66,20 @@ def courses(request):
# Checking if the Training type with the same version already exists
existing_course = Course.objects.filter(training_type=training_type)
if existing_course.exists():
- # checking if the version number is valid
- if not all(map(lambda x: x.isdigit() or x == '.', str(file_data['courses'][0]['version']))):
+ existing_course_version = existing_course.order_by('-version').first().version
+ new_course_version = file_data['courses'][0]['version']
+ # checking if the new course file has a valid version
+ if not validate_version(file_data['courses'][0]['version']):
messages.error(request, 'Version number is not valid.')
# checking if the version number is greater than the existing version
elif float(file_data['courses'][0]['version']
) <= float(existing_course.order_by('-version').first().version):
messages.error(request, 'Version number should be greater than the existing version.')
- else: # Checks passed and moving to saving the course
+ else:
serializer = TrainingTypeSerializer(training_type, data=file_data, partial=True)
if serializer.is_valid(raise_exception=False):
- # A Major Version change is detected : The First digit of the version number is changed
- if int(str(existing_course.order_by('-version').first().version).split('.')[0]) != int(str(
- file_data['courses'][0]['version']).split('.')[0]):
+ if is_major_change(new_course_version,
+ existing_course_version):
# calling the update_course_for_major_version_change method to update the course
existing_course[0].update_course_for_major_version_change(training_type)
serializer.save()
@@ -85,6 +106,11 @@ def courses(request):
@permission_required('training.change_course', raise_exception=True)
def download_course(request, pk, version):
+ """
+ This view takes a primary key and a version number as input parameters,
+ and returns a JSON response containing information about the
+ training course with the specified primary key and version number.
+ """
training_type = get_object_or_404(TrainingType, pk=pk)
version = float(version)
if training_type.required_field != RequiredField.PLATFORM:
From 683564e378f6093f23b24cd4f0eac97d6ee19174 Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Mon, 20 Nov 2023 14:46:00 -0500
Subject: [PATCH 130/181] added the logic for version check in validators
---
physionet-django/project/validators.py | 11 +++++++++++
physionet-django/training/views.py | 7 +++----
2 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/physionet-django/project/validators.py b/physionet-django/project/validators.py
index 6348c9d292..1aaf74bf7e 100644
--- a/physionet-django/project/validators.py
+++ b/physionet-django/project/validators.py
@@ -2,6 +2,7 @@
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
+from packaging.version import Version
MAX_FILENAME_LENGTH = 100
MAX_PROJECT_SLUG_LENGTH = 30
@@ -95,6 +96,16 @@ def validate_version(value):
'For example, write "1.0.1" instead of "1.00.01".')
+def is_version_greater(version1, version2):
+ """
+ Return True if version1 is greater than version2, False otherwise.
+ """
+ try:
+ return Version(version1) > Version(version2)
+ except ValueError:
+ return ValueError('Invalid version number.')
+
+
def validate_slug(value):
"""
Validate a published slug. Not ending with dash number for google
diff --git a/physionet-django/training/views.py b/physionet-django/training/views.py
index c503baa8ad..3e6760e55f 100644
--- a/physionet-django/training/views.py
+++ b/physionet-django/training/views.py
@@ -11,7 +11,7 @@
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.crypto import get_random_string
-from project.validators import validate_version
+from project.validators import validate_version, is_version_greater
from rest_framework.parsers import JSONParser
@@ -69,11 +69,10 @@ def courses(request):
existing_course_version = existing_course.order_by('-version').first().version
new_course_version = file_data['courses'][0]['version']
# checking if the new course file has a valid version
- if not validate_version(file_data['courses'][0]['version']):
+ if validate_version(new_course_version) is not None:
messages.error(request, 'Version number is not valid.')
# checking if the version number is greater than the existing version
- elif float(file_data['courses'][0]['version']
- ) <= float(existing_course.order_by('-version').first().version):
+ elif not is_version_greater(new_course_version, existing_course_version):
messages.error(request, 'Version number should be greater than the existing version.')
else:
serializer = TrainingTypeSerializer(training_type, data=file_data, partial=True)
From 0b1df689cada9252a65da46af43c84889e34912d Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Tue, 21 Nov 2023 23:17:51 -0500
Subject: [PATCH 131/181] Fixed the course download functionality
---
physionet-django/training/views.py | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/physionet-django/training/views.py b/physionet-django/training/views.py
index 3e6760e55f..a897aba3da 100644
--- a/physionet-django/training/views.py
+++ b/physionet-django/training/views.py
@@ -20,7 +20,7 @@
from training.models import Course, Quiz, QuizChoice, ContentBlock
from training.models import CourseProgress, ModuleProgress, CompletedContent, CompletedQuiz
-from training.serializers import TrainingTypeSerializer
+from training.serializers import TrainingTypeSerializer, CourseSerializer
# Utility Functions
@@ -110,13 +110,16 @@ def download_course(request, pk, version):
and returns a JSON response containing information about the
training course with the specified primary key and version number.
"""
- training_type = get_object_or_404(TrainingType, pk=pk)
- version = float(version)
+ course = Course.objects.filter(training_type__pk=pk, version=version).first()
+ if not course:
+ messages.error(request, 'Course not found')
+ return redirect('courses')
+ training_type = course.training_type
if training_type.required_field != RequiredField.PLATFORM:
messages.error(request, 'Only onplatform course can be downloaded')
return redirect('courses')
- serializer = TrainingTypeSerializer(training_type)
+ serializer = CourseSerializer(course)
response_data = serializer.data
response = JsonResponse(response_data, safe=False, json_dumps_params={'indent': 2})
response['Content-Disposition'] = f'attachment; filename={training_type.name}--version-{version}.json'
From 471bc0ee4fe678561fac5d1b875e13f7395a91fb Mon Sep 17 00:00:00 2001
From: rutvikrj26
Date: Sun, 26 Nov 2023 17:44:55 -0500
Subject: [PATCH 132/181] added the UI & views for ability to expire/download
individual course versions
---
.../console/training_type/course_details.html | 67 +++++++++++++++
.../console/training_type/index.html | 86 ++++++++++---------
physionet-django/console/urls.py | 4 +-
.../migrations/0003_course_is_active.py | 18 ++++
physionet-django/training/models.py | 16 +++-
physionet-django/training/views.py | 40 ++++++++-
6 files changed, 181 insertions(+), 50 deletions(-)
create mode 100644 physionet-django/console/templates/console/training_type/course_details.html
create mode 100644 physionet-django/training/migrations/0003_course_is_active.py
diff --git a/physionet-django/console/templates/console/training_type/course_details.html b/physionet-django/console/templates/console/training_type/course_details.html
new file mode 100644
index 0000000000..0a15b03400
--- /dev/null
+++ b/physionet-django/console/templates/console/training_type/course_details.html
@@ -0,0 +1,67 @@
+{% extends "console/base_console.html" %}
+
+{% load static %}
+
+{% block title %}{{ training_type }}{% endblock %}
+
+{% block content %}
+
+
+ {{ training_type }}
+
+
+
Active Versions
+
+
+
+
+
Name
+
Version
+
Action
+
+
+
+ {% for course in active_course_versions %}
+
+