diff --git a/.env.example b/.env.example
index bdb593a99e..31f34d5ab7 100644
--- a/.env.example
+++ b/.env.example
@@ -23,6 +23,10 @@ CONTACT_EMAIL='PhysioNet Contact '
SERVER_EMAIL='PhysioNet System '
ERROR_EMAIL='contact@physionet.org'
+# Contact address for project editors. This address may be viewable by authors.
+# Optionally, add "PROJECT-SLUG" to include the project slug.
+PROJECT_EDITOR_EMAIL='editor+PROJECT-SLUG@dev.physionet.org'
+
# Admins
ADMINS_NAME=PhysioNet Technical
ADMINS_MAIL=technical@dev.physionet.org
@@ -60,6 +64,18 @@ AWS_VALUE=secret
AWS_VALUE2=secret
AWS_CLOUD_FORMATION=url
+# AWS credentials (Access Key and Secret Key): Configure AWS credentials in the AWS CLI profile using the 'aws configure' command.
+AWS_PROFILE=
+# AWS account ID
+AWS_ACCOUNT_ID=
+# Path to the file containing credentials for AWS
+# (https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html#shared-credentials-file)
+AWS_SHARED_CREDENTIALS_FILE=
+# The default bucket name to store projects with an 'OPEN' access policy.
+S3_OPEN_ACCESS_BUCKET=
+# The default bucket name to store logs and metrics related to project usage.
+S3_SERVER_ACCESS_LOG_BUCKET=
+
# Datacite
# Used to assign the DOIs
# Changing the password can be done at the settings tab in DataCite website
@@ -158,6 +174,10 @@ GRADIENT_85 = 'rgba(42, 47, 52, 0.85)'
# maximum number of emails that can be associated to a user model
MAX_EMAILS_PER_USER = 10
+# maximum number of active projects that can be created by a submitting author at any time.
+# if MAX_SUBMITTABLE_PROJECTS is reached, the user must wait for a project to be archived or published before starting another.
+MAX_SUBMITTABLE_PROJECTS = 10
+
# Max training report size in bytes
MAX_TRAINING_REPORT_UPLOAD_SIZE = 1048576
ENABLE_LIGHTWAVE=True
diff --git a/README.md b/README.md
index a8222726fc..1a9c3328e7 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,12 @@ The code should dynamically reload in development, however, if there are any iss
Docker-compose uses volumes to persist the database contents and data directories (media and static files). To clean up the created containers, networks and volumes stop `docker-compose up` and run `docker-compose down -v`. Do not run `docker-compose down -v` if you want to retain current database contents.
+## Background tasks
+
+Background tasks are managed by [Django Q2](https://django-q2.readthedocs.io/en/master/), "a native Django task queue, scheduler and worker application using Python multiprocessing".
+
+If you would like to run background tasks on your development server, you will need to start the task manager with `python manage.py qcluster`
+
## Using a debugger with Docker
To access a debug prompt raised using `breakpoint()`:
diff --git a/demo-files/aws_credentials b/demo-files/aws_credentials
new file mode 100644
index 0000000000..5c0206fd22
--- /dev/null
+++ b/demo-files/aws_credentials
@@ -0,0 +1,3 @@
+[default]
+aws_access_key_id = AKIAZZZZZZZZZZZZZZZZ
+aws_secret_access_key = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
diff --git a/deploy/common/etc/sudoers.d/physionet b/deploy/common/etc/sudoers.d/physionet
index fd522bcee2..3a0260ec37 100644
--- a/deploy/common/etc/sudoers.d/physionet
+++ b/deploy/common/etc/sudoers.d/physionet
@@ -16,3 +16,7 @@ pn ALL=(root:root) NOPASSWD: \
# Restart django-background-tasks.service (if it is running)
pn ALL=(root:root) NOPASSWD: \
/bin/systemctl try-restart django-background-tasks.service
+
+# Restart django-q2-tasks.service (if it is running)
+pn ALL=(root:root) NOPASSWD: \
+ /bin/systemctl try-restart django-q2-tasks.service
diff --git a/deploy/post-receive b/deploy/post-receive
index f4edc973cf..aec4db3227 100755
--- a/deploy/post-receive
+++ b/deploy/post-receive
@@ -112,6 +112,7 @@ else
fi
# Restart the django-background-tasks server
+# Background tasks will soon be migrated to Django Q2
(
cd $working_dir/physionet-django
if cmp -s OLD-TARGETS LATE-TARGETS; then
@@ -130,6 +131,25 @@ fi
fi
)
+# Restart the Django Q2 server
+(
+ cd $working_dir/physionet-django
+ if cmp -s OLD-TARGETS LATE-TARGETS; then
+ # assume that if there are no migrations, no need to restart
+ # background tasks (and don't even bother showing a message)
+ :
+ elif [ -n "$no_bgtasks" ]; then
+ echo "- SKIPPING restarting django-q2-tasks due to"
+ echo " --push-option=no-bgtasks"
+ elif [ -n "$no_install" ]; then
+ echo "- SKIPPING restarting django-q2-tasks due to"
+ echo " --push-option=no-install"
+ else
+ echo "* Restarting django-q2-tasks..."
+ sudo systemctl try-restart django-q2-tasks.service
+ fi
+)
+
# Run late migrations (as well as early migrations, if they were
# skipped before)
(
diff --git a/deploy/production/etc/rsyslog.d/django_q.conf b/deploy/production/etc/rsyslog.d/django_q.conf
new file mode 100644
index 0000000000..e440311714
--- /dev/null
+++ b/deploy/production/etc/rsyslog.d/django_q.conf
@@ -0,0 +1,3 @@
+# Filter to get all background tasks and send them to a custom location
+:programname, isequal, "django-q2-tasks" -/data/log/background_tasks/django_q_tasks.log
+& stop
diff --git a/deploy/production/etc/systemd/system/django-q2-tasks.service b/deploy/production/etc/systemd/system/django-q2-tasks.service
new file mode 100644
index 0000000000..facb33eee3
--- /dev/null
+++ b/deploy/production/etc/systemd/system/django-q2-tasks.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=Command that runs Django Q2 tasks
+After=emperor.uwsgi.service
+
+[Service]
+Environment=DJANGO_SETTINGS_MODULE=physionet.settings.production
+ExecStart=/physionet/python-env/physionet/bin/python /physionet/physionet-build/physionet-django/manage.py qcluster
+StandardError=syslog
+SyslogIdentifier=django-q2-tasks
+Restart=always
+KillSignal=SIGINT
+Type=simple
+NotifyAccess=all
+User=www-data
+Group=www-data
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/staging/etc/rsyslog.d/django_q.conf b/deploy/staging/etc/rsyslog.d/django_q.conf
new file mode 100644
index 0000000000..e440311714
--- /dev/null
+++ b/deploy/staging/etc/rsyslog.d/django_q.conf
@@ -0,0 +1,3 @@
+# Filter to get all background tasks and send them to a custom location
+:programname, isequal, "django-q2-tasks" -/data/log/background_tasks/django_q_tasks.log
+& stop
diff --git a/deploy/staging/etc/systemd/system/django-q2-tasks.service b/deploy/staging/etc/systemd/system/django-q2-tasks.service
new file mode 100644
index 0000000000..6cfbfc233f
--- /dev/null
+++ b/deploy/staging/etc/systemd/system/django-q2-tasks.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=Command that runs Django Q2 tasks
+After=emperor.uwsgi.service
+
+[Service]
+Environment=DJANGO_SETTINGS_MODULE=physionet.settings.staging
+ExecStart=/physionet/python-env/physionet/bin/python /physionet/physionet-build/physionet-django/manage.py qcluster
+StandardError=syslog
+SyslogIdentifier=django-q2-tasks
+Restart=always
+KillSignal=SIGINT
+Type=simple
+NotifyAccess=all
+User=www-data
+Group=www-data
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/test-server/install-pn-test-server b/deploy/test-server/install-pn-test-server
index 68e9c9d23c..a48652a0cf 100755
--- a/deploy/test-server/install-pn-test-server
+++ b/deploy/test-server/install-pn-test-server
@@ -234,9 +234,14 @@ systemctl daemon-reload
systemctl enable emperor.uwsgi
systemctl restart emperor.uwsgi
systemctl restart nginx
+
+# django-background-tasks will be replaced with Django Q2 soon
systemctl enable django-background-tasks
systemctl restart django-background-tasks
+systemctl enable django-q2-tasks
+systemctl restart django-q2-tasks
+
su pn -c '
cd /physionet/physionet-build.git
echo
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/console/forms.py b/physionet-django/console/forms.py
index c085efaf12..943bd5c33f 100644
--- a/physionet-django/console/forms.py
+++ b/physionet-django/console/forms.py
@@ -223,9 +223,6 @@ def save(self):
# Reject
if edit_log.decision == 0:
project.reject()
- # Have to reload this object which is changed by the reject
- # function
- edit_log = EditLog.objects.get(id=edit_log.id)
# Resubmit with revisions
elif edit_log.decision == 1:
project.submission_status = SubmissionStatus.NEEDS_RESUBMISSION
@@ -635,7 +632,7 @@ class NewsForm(forms.ModelForm):
class Meta:
model = News
- fields = ('title', 'content', 'url', 'project', 'front_page_banner')
+ fields = ('slug', 'title', 'content', 'url', 'project', 'link_all_versions', 'front_page_banner')
class FeaturedForm(forms.Form):
diff --git a/physionet-django/console/navbar.py b/physionet-django/console/navbar.py
new file mode 100644
index 0000000000..1a06ec823d
--- /dev/null
+++ b/physionet-django/console/navbar.py
@@ -0,0 +1,220 @@
+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(_('Cloud'), 'cloud', 'cloud', [
+ NavLink(_('Mirrors'), 'cloud_mirrors'),
+ ]),
+
+ 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']),
+
+ NavLink(_('Courses'), 'courses', 'chalkboard-teacher'),
+
+ 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/services.py b/physionet-django/console/services.py
index 18218cc088..9edcf9651e 100644
--- a/physionet-django/console/services.py
+++ b/physionet-django/console/services.py
@@ -4,7 +4,7 @@
from typing import Optional
from pdfminer.high_level import extract_text
-from pdfminer.pdfparser import PDFSyntaxError
+from pdfminer.pdfparser import PDFException
from django.conf import settings
from user.models import Training
@@ -29,7 +29,7 @@ def _get_regex_value_from_text(text: str, regex: str) -> Optional[str]:
def _parse_pdf_to_string(training_path: str) -> str:
try:
text = extract_text(training_path)
- except PDFSyntaxError:
+ except PDFException:
text = ''
logging.error(f'Failed to extract text from {training_path}')
return ' '.join(text.split())
diff --git a/physionet-django/console/static/console/css/cloud-mirrors.css b/physionet-django/console/static/console/css/cloud-mirrors.css
new file mode 100644
index 0000000000..d01bc8fc12
--- /dev/null
+++ b/physionet-django/console/static/console/css/cloud-mirrors.css
@@ -0,0 +1,91 @@
+.table-cloud-status {
+ table-layout: fixed;
+ width: 100%;
+}
+
+.table-cloud-status .col-project-version {
+ width: 7rem;
+}
+.table-cloud-status .col-project-site-status,
+.table-cloud-status .col-project-cloud-status {
+ width: 3rem;
+ text-align: center;
+ overflow-x: hidden;
+}
+
+.project-site-status-title,
+.project-cloud-status-title {
+ font-size: 0;
+}
+.project-site-status-title::before,
+.project-cloud-status-title::before {
+ font-size: 1rem;
+ display: inline-block;
+ width: 1.25em;
+ font-family: "Font Awesome 5 Free";
+ font-weight: 900;
+}
+.project-site-status-title::before {
+ content: "\f019"; /* download */
+}
+.project-cloud-status-title::before {
+ content: "\f381"; /* cloud-download-alt */
+}
+.col-gcp .project-cloud-status-title::before {
+ font-family: "Font Awesome 5 Brands";
+ font-weight: 400;
+ content: "\f1a0"; /* google */
+}
+.col-aws .project-cloud-status-title::before {
+ font-family: "Font Awesome 5 Brands";
+ font-weight: 400;
+ content: "\f375"; /* aws */
+}
+
+.project-site-status-open,
+.project-site-status-restricted,
+.project-site-status-embargo,
+.project-site-status-forbidden,
+.project-cloud-status-public,
+.project-cloud-status-private,
+.project-cloud-status-pending,
+.project-cloud-status-none {
+ font-size: 0;
+}
+.project-site-status-open::before,
+.project-site-status-restricted::before,
+.project-site-status-embargo::before,
+.project-site-status-forbidden::before,
+.project-cloud-status-public::before,
+.project-cloud-status-private::before,
+.project-cloud-status-pending::before,
+.project-cloud-status-none::before {
+ font-size: 1rem;
+ display: inline-block;
+ width: 1.25em;
+ font-family: "Font Awesome 5 Free";
+ font-weight: 900;
+}
+.project-site-status-open::before,
+.project-cloud-status-public::before {
+ content: "\f058"; /* check-circle */
+ color: #0a0;
+}
+.project-site-status-restricted::before,
+.project-cloud-status-private::before {
+ content: "\f2bd"; /* user-circle */
+ color: #50f;
+}
+.project-site-status-embargo::before {
+ content: "\f28b"; /* pause-circle */
+ color: #fa0;
+}
+.project-site-status-forbidden::before {
+ content: "\f057"; /* times-circle */
+ color: #c00;
+}
+.project-cloud-status-pending::before {
+ content: "\f017"; /* clock */
+ font-weight: 400;
+ color: #888;
+}
diff --git a/physionet-django/console/static/console/css/console-noscript.css b/physionet-django/console/static/console/css/console-noscript.css
new file mode 100644
index 0000000000..ea7eb868d6
--- /dev/null
+++ b/physionet-django/console/static/console/css/console-noscript.css
@@ -0,0 +1,8 @@
+.navbar-sidenav .nav-item:focus-within .collapse,
+.navbar-sidenav .nav-link-collapse:focus + .collapse {
+ display: block;
+}
+
+#mainNav .navbar-collapse .navbar-sidenav .nav-item:focus-within .nav-link-collapse.collapsed::after {
+ content: "\f107"; /* angle-down */
+}
diff --git a/physionet-django/console/templates/console/base_console.html b/physionet-django/console/templates/console/base_console.html
index 9cef68aa44..8207ecbe77 100644
--- a/physionet-django/console/templates/console/base_console.html
+++ b/physionet-django/console/templates/console/base_console.html
@@ -7,6 +7,7 @@
{% include "base_css.html" %}
+
{% block local_css %}{% endblock %}
{% include "base_js_top.html" %}
{% block local_js_top %}{% endblock %}
diff --git a/physionet-django/console/templates/console/cloud_mirrors.html b/physionet-django/console/templates/console/cloud_mirrors.html
new file mode 100644
index 0000000000..eafa471146
--- /dev/null
+++ b/physionet-django/console/templates/console/cloud_mirrors.html
@@ -0,0 +1,96 @@
+{% extends "console/base_console.html" %}
+
+{% load static %}
+
+{% block title %}Cloud Mirrors{% endblock %}
+
+{% block local_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ Project
+ Version
+
+
+ {{ SITE_NAME }}
+
+
+ {% for platform in cloud_platforms %}
+
+
+ {{ platform.name }}
+
+
+ {% endfor %}
+
+
+
+ {% for project, mirrors in project_mirrors.items %}
+
+
+
+ {{ project.title }}
+
+
+ {{ project.version }}
+
+ {% if project.deprecated_files %}
+ Deprecated
+ {% elif not project.allow_file_downloads %}
+ Forbidden
+ {% elif project.embargo_active %}
+ Embargo
+ {% elif project.access_policy != AccessPolicy.OPEN %}
+ Restricted
+ {% else %}
+ Open
+ {% endif %}
+
+ {% for platform_mirror in mirrors %}
+
+ {% if not platform_mirror %}
+
+ {% elif not platform_mirror.sent_files %}
+ Pending
+ {% elif platform_mirror.is_private %}
+ Private
+ {% else %}
+ Public
+ {% endif %}
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/physionet-django/console/templates/console/console_navbar.html b/physionet-django/console/templates/console/console_navbar.html
index 3ffd116272..61f96aceaf 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 %}
☰
@@ -10,353 +11,39 @@
diff --git a/physionet-django/console/templates/console/event_management.html b/physionet-django/console/templates/console/event_management.html
index 1ec760e079..2e97085ae4 100644
--- a/physionet-django/console/templates/console/event_management.html
+++ b/physionet-django/console/templates/console/event_management.html
@@ -38,42 +38,53 @@ {{ event.title }}
{% endif %}
+
+ {% for info in applicant_info %}
-
Total participants:
+
{{ info.title }}
-
{{ event.participants.count }}
View participants
+ data-target="#{{ info.id }}">{{ info.count }} - View
+ {% endfor %}
+
Description:
-
{{ event.description }}
+
{{ event.description|striptags }}
+
{% include 'console/event_management_manage_dataset.html' %}
+
+ {% for info in applicant_info %}
+ id="{{ info.id }}"
+ tabindex="-1"
+ role="dialog"
+ aria-labelledby="view-{{ info.id }}-modal"
+ aria-hidden="true">
- {% include 'events/event_entries.html' %}
+ {% include 'events/event_applications.html' %}
+ {% endfor %}
+
{% endblock %}
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..12737f25b9
--- /dev/null
+++ b/physionet-django/console/templates/console/guidelines_course.html
@@ -0,0 +1,162 @@
+{% extends "console/base_console.html" %}
+{% load static %}
+{% block title %}Guidelines for Courses{% endblock %}
+
+{% block content %}
+
+
+
+
+
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 the
+ 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
+
+
General Schema Explanation
+
+{
+ "name": "string",
+ "description": "string",
+ "valid_duration": "string",
+ "courses": [{
+ "version": "string",
+ "modules": [
+ {
+ "contents": [
+ {
+ "body": "string",
+ "order": "integer"
+ }
+ ],
+ "quizzes": [
+ {
+ "question": "string",
+ "order": "integer",
+ "choices": [
+ {
+ "body": "string",
+ "is_correct": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }]
+}
+
+
+ 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. Each module contains the course content and quizzes.
+ Modules must include:
+ 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 blocks. Each content block 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.
+
+"contents": [
+ {
+ "body": "This content will display first for the given module.",
+ "order": 1
+ },
+ {
+ "body": "This content will appear third (order = 3), after the quiz below.",
+ "order": 3
+ }
+],
+"quizzes": [
+ {
+ "question": "This quiz will appear after the first content block (order = 2).",
+ "order": 2,
+ "choices": [
+ {
+ "body": "This answer is correct. The user will proceed to the next block if they answer it.",
+ "is_correct": true
+ },
+ {
+ "body": "This choice is incorrect. The user will return to the beginning of the module if they answer it.",
+ "is_correct": false
+ }
+ ]
+ },
+ {
+ "question": "This quiz will appear last, after the final content block (order = 4).",
+ "order": 4,
+ "choices": [
+ {
+ "body": "choice 1",
+ "is_correct": true
+ },
+ {
+ "body": "choice 2",
+ "is_correct": false
+ }
+ ]
+ }
+]
+
+
+
+
+
+ Choices: A quiz may have one or more choices. Each choice should include:
+
+ A body for the choice
+ A boolean value of true for the correct answer
+
+
+
+
+
+{% endblock %}
diff --git a/physionet-django/console/templates/console/manage_published_project.html b/physionet-django/console/templates/console/manage_published_project.html
index de933752d6..fda1899ba0 100644
--- a/physionet-django/console/templates/console/manage_published_project.html
+++ b/physionet-django/console/templates/console/manage_published_project.html
@@ -408,6 +408,32 @@ Google Cloud
If this message is here for a long time, check the Django "process_tasks"
{% endif %}
+
+ AWS
+ {% if not has_s3_credentials %}
+
+ Support for Amazon S3 is not enabled. (Check that
+ AWS_PROFILE
and S3_OPEN_ACCESS_BUCKET
+ are set.)
+
+ {% elif not project.aws.bucket_name %}
+ Create a bucket on AWS to store the files associated with this project.
+
+ {% elif project.aws.sent_files and project.aws.bucket_name %}
+ The files have been sent to AWS. The bucket name is: {{project.aws.bucket_name}}.
+
+ {% endif %}
+
diff --git a/physionet-django/console/templates/console/news_list.html b/physionet-django/console/templates/console/news_list.html
index 64e1348268..c20b0360ae 100644
--- a/physionet-django/console/templates/console/news_list.html
+++ b/physionet-django/console/templates/console/news_list.html
@@ -4,6 +4,6 @@
{{ news.publish_datetime }}
{% if news.url %}{{ news.url }} {% else %}None{% endif %}
{% if news.front_page_banner %}✔ {% endif %}
- Edit
+ Edit
{% endfor %}
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..67ca59d067
--- /dev/null
+++ b/physionet-django/console/templates/console/training_type/course_details.html
@@ -0,0 +1,126 @@
+{% extends "console/base_console.html" %}
+
+{% load static %}
+
+{% block title %}{{ training_type }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
Active Versions
+
+
+
+
+ Name
+ Version
+ Download
+ Expire
+
+
+
+ {% for course in active_course_versions %}
+
+
+ {{ training_type.name|title }}
+ {{ course.version }}
+
+ Download
+
+
+
+
+
+ {% endfor %}
+
+
+
Note: Users that have taken the particular version of the course that is getting expired,
+ will need to retake the course or else they will loose credentialing after the number of
+ days specified above while expiring the course.
+
+
Archived Versions
+
+
+
+
+ Name
+ Version
+ Action
+
+
+
+ {% for course in inactive_course_versions %}
+
+
+ {{ training_type.name|title }}
+ {{ course.version }}
+
+ Download
+
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
+
+
\ No newline at end of file
diff --git a/physionet-django/console/templates/console/training_type/index.html b/physionet-django/console/templates/console/training_type/index.html
new file mode 100644
index 0000000000..76f43e176c
--- /dev/null
+++ b/physionet-django/console/templates/console/training_type/index.html
@@ -0,0 +1,80 @@
+{% extends "console/base_console.html" %}
+
+{% load static %}
+
+{% block title %}{{ SITE_NAME }} Courses{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Create
+
+
+
+
+ Name
+ Valid Duration
+ Latest Version
+
+
+
+ {% for training in training_types %}
+
+
+ {{ training.name|title }}
+ {{ training.valid_duration.days }} days
+ {{ training.courses.last.version }}
+
+ {% endfor %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/physionet-django/console/templatetags/console_templatetags.py b/physionet-django/console/templatetags/console_templatetags.py
index d61424db7e..09d44f46a1 100644
--- a/physionet-django/console/templatetags/console_templatetags.py
+++ b/physionet-django/console/templatetags/console_templatetags.py
@@ -1,10 +1,33 @@
from django import template
+from console.navbar import CONSOLE_NAV_MENU
import notification.utility as notification
register = template.Library()
+@register.simple_tag
+def console_nav_menu_items(request):
+ """
+ Get a list of menu items to be shown in the navigation bar.
+
+ Each menu item is a dictionary, representing either a link or a
+ submenu that contains one or more links. The argument should be
+ the original HTTP request object; request.user determines which
+ menu items are visible, and request.path determines which menu
+ items are marked as "active".
+
+ Typically the return value of this tag will be assigned to a
+ template variable, e.g.:
+
+ {% console_nav_menu_items request as nav_menu_items %}
+ {% for item in nav_menu_items %}
+ ...
+ {% endfor %}
+ """
+ return CONSOLE_NAV_MENU.get_menu_items(request)
+
+
@register.filter(name='task_count_badge')
def task_count_badge(item):
"""
diff --git a/physionet-django/console/test_views.py b/physionet-django/console/test_views.py
index c5be229c41..a1f41a7ac1 100644
--- a/physionet-django/console/test_views.py
+++ b/physionet-django/console/test_views.py
@@ -11,7 +11,6 @@
from events.models import EventAgreement
from project.models import (
ActiveProject,
- ArchivedProject,
Author,
AuthorInvitation,
License,
@@ -97,8 +96,10 @@ def test_edit_reject(self):
'data_machine_readable':0, 'reusable':1, 'no_phi':0,
'pn_suitable':1, 'editor_comments':'Just bad.', 'decision':0
})
- self.assertTrue(ArchivedProject.objects.filter(slug=project.slug))
- self.assertFalse(ActiveProject.objects.filter(slug=project.slug))
+ self.assertTrue(ActiveProject.objects.filter(slug=project.slug,
+ submission_status=SubmissionStatus.ARCHIVED))
+ self.assertFalse(ActiveProject.objects.filter(slug=project.slug,
+ submission_status=SubmissionStatus.NEEDS_DECISION))
def test_edit(self):
"""
diff --git a/physionet-django/console/urls.py b/physionet-django/console/urls.py
index 11e91f4cdc..af98a9beaf 100644
--- a/physionet-django/console/urls.py
+++ b/physionet-django/console/urls.py
@@ -1,4 +1,5 @@
from console import views
+from training import views as training_views
from django.urls import path
urlpatterns = [
@@ -19,6 +20,8 @@
path('published-projects///',
views.manage_published_project, name='manage_published_project'),
path('data-access-request//', views.access_request, name='access_request'),
+ path('cloud/mirrors/', views.cloud_mirrors,
+ name='cloud_mirrors'),
# Logs
path('data-access-logs/', views.project_access_requests_list, name='project_access_requests_list'),
@@ -79,21 +82,21 @@
path('users/groups/', views.user_groups, name='user_groups'),
path('users/groups//', views.user_group, name='user_group'),
path('users//', views.users, name='users'),
- path('users/aws-access-list.json', views.users_aws_access_list_json,
- name='users_aws_access_list_json'),
path('user/manage//', views.user_management,
name='user_management'),
path('news/', views.news_console, name='news_console'),
path('news/add/', views.news_add, name='news_add'),
path('news/search/', views.news_search, name='news_search'),
- path('news/edit//', views.news_edit, name='news_edit'),
+ path('news/edit//', views.news_edit, name='news_edit'),
path('featured/', views.featured_content, name='featured_content'),
path('featured/add', views.add_featured, name='add_featured'),
# guidelines
path('guidelines/review/', views.guidelines_review, name='guidelines_review'),
+ path('guidelines/course/', views.guidelines_course, name='guidelines_course'),
+
path('user-autocomplete/', views.UserAutocomplete.as_view(), name='user-autocomplete'),
path('project-autocomplete/', views.ProjectAutocomplete.as_view(), name='project-autocomplete'),
@@ -157,6 +160,14 @@
path('event_agreements//new-version/', views.event_agreement_new_version,
name='event_agreement_new_version'),
path('console/user/manage//', views.event_invite_host, name='event_invite_host'),
+
+ # Courses/On Platform Training
+ path('courses/', training_views.courses, name='courses'),
+ path('courses//', training_views.course_details, name='course_details'),
+ path('courses//download/',
+ training_views.download_course, name='download_course_version'),
+ path('courses//expire/',
+ training_views.expire_course, name='expire_course_version'),
]
# Parameters for testing URLs (see physionet/test_urls.py)
@@ -170,6 +181,9 @@
'section_pk': 1,
'news_id': 1,
'username': 'rgmark',
+ 'news_slug': 'cloud-migration',
+ 'version': '1.0',
+ 'training_slug': 'world-101-introduction-to-continents-and-countries',
}
TEST_CASES = {
'manage_published_project': {
diff --git a/physionet-django/console/utility.py b/physionet-django/console/utility.py
index e3f531c90d..e6b703db6c 100644
--- a/physionet-django/console/utility.py
+++ b/physionet-django/console/utility.py
@@ -13,7 +13,6 @@
from django.contrib.sites.models import Site
from django.conf import settings
from django.urls import reverse
-
from project.validators import validate_doi
import logging
@@ -31,6 +30,7 @@ class DOIExistsError(Exception):
class DOICreationError(Exception):
pass
+# Manage GCP buckets
def check_bucket_exists(project, version):
"""
@@ -42,7 +42,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.
@@ -489,6 +488,7 @@ def generate_doi_payload(project, core_project=False, event="draft"):
author_metadata = {"givenName": author.first_names,
"familyName": author.last_name,
"name": author.get_full_name(reverse=True)}
+ author_metadata["affiliation"] = [{"name": a.name} for a in author.affiliations.all()]
if author.user.has_orcid():
author_metadata["nameIdentifiers"] = [{
"nameIdentifier": f'https://orcid.org/{author.user.get_orcid_id()}',
diff --git a/physionet-django/console/views.py b/physionet-django/console/views.py
index 171d2890fa..8714ac4dc4 100644
--- a/physionet-django/console/views.py
+++ b/physionet-django/console/views.py
@@ -1,7 +1,6 @@
import csv
import logging
import os
-import re
from collections import OrderedDict
from datetime import datetime
from itertools import chain
@@ -28,7 +27,7 @@
from django.utils import timezone
from django.core.exceptions import PermissionDenied
from events.forms import EventAgreementForm, EventDatasetForm
-from events.models import Event, EventAgreement, EventDataset
+from events.models import Event, EventAgreement, EventDataset, EventApplication
from notification.models import News
from physionet.forms import set_saved_fields_cookie
from physionet.middleware.maintenance import ServiceUnavailable
@@ -38,10 +37,10 @@
from project.models import (
GCP,
GCPLog,
+ AWS,
AccessLog,
AccessPolicy,
ActiveProject,
- ArchivedProject,
DataAccess,
DUA,
DataAccessRequest,
@@ -75,7 +74,14 @@
from physionet.enums import LogCategory
from console import forms, utility, services
from console.forms import ProjectFilterForm, UserFilterForm
-
+from project.cloud.s3 import (
+ create_s3_bucket,
+ upload_project_to_S3,
+ get_bucket_name,
+ check_s3_bucket_exists,
+ update_bucket_policy,
+ has_s3_credentials,
+)
LOGGER = logging.getLogger(__name__)
@@ -121,16 +127,35 @@ def handling_view(request, *args, **kwargs):
raise Http404('Unable to access page')
return handling_view
+
+def console_permission_required(perm):
+ """
+ Decorator for a view that requires user permissions.
+
+ If the client is not logged in, or the user doesn't have the
+ specified permission, the view raises PermissionDenied.
+
+ The required permission name is also stored as an attribute for
+ introspection purposes.
+ """
+ def wrapper(view):
+ view = permission_required(perm, raise_exception=True)(view)
+ view.required_permission = perm
+ return view
+ return wrapper
+
+
# ------------------------- Views begin ------------------------- #
+@console_permission_required('user.can_view_admin_console')
def console_home(request):
if not request.user.is_authenticated or not request.user.has_access_to_admin_console():
raise PermissionDenied
- return render(request, 'console/console_home.html', {'console_home_nav': True})
+ return render(request, 'console/console_home.html')
-@permission_required('project.change_activeproject', raise_exception=True)
+@console_permission_required('project.change_activeproject')
def submitted_projects(request):
"""
List of active submissions. Editors are assigned here.
@@ -147,7 +172,7 @@ def submitted_projects(request):
messages.success(request, 'The editor has been assigned')
# Submitted projects
- projects = ActiveProject.objects.filter(submission_status__gt=SubmissionStatus.UNSUBMITTED).order_by(
+ projects = ActiveProject.objects.filter(submission_status__gt=SubmissionStatus.ARCHIVED).order_by(
'submission_datetime')
# Separate projects by submission status
# Awaiting editor assignment
@@ -196,11 +221,10 @@ def submitted_projects(request):
'copyedit_projects': copyedit_projects,
'approval_projects': approval_projects,
'publish_projects': publish_projects,
- 'submitted_projects_nav': True,
'yesterday': yesterday})
-@permission_required('project.change_activeproject', raise_exception=True)
+@console_permission_required('project.change_activeproject')
def editor_home(request):
"""
List of submissions the editor is responsible for
@@ -246,7 +270,7 @@ def submission_info_redirect(request, project_slug):
return redirect('submission_info', project_slug=project_slug)
-@permission_required('project.change_activeproject', raise_exception=True)
+@console_permission_required('project.change_activeproject')
def submission_info(request, project_slug):
"""
View information about a project under submission
@@ -300,8 +324,7 @@ def submission_info(request, project_slug):
'anonymous_url': anonymous_url, 'url_prefix': url_prefix,
'bulk_url_prefix': bulk_url_prefix,
'reassign_editor_form': reassign_editor_form,
- 'embargo_form': embargo_form,
- 'project_info_nav': True})
+ 'embargo_form': embargo_form})
@handling_editor
@@ -334,7 +357,8 @@ def edit_submission(request, project_slug, *args, **kwargs):
edit_log.set_quality_assurance_results()
# The original object will be deleted if the decision is reject
if edit_log.decision == 0:
- project = ArchivedProject.objects.get(slug=project_slug)
+ project = ActiveProject.objects.get(slug=project_slug,
+ submission_status=SubmissionStatus.ARCHIVED)
# Notify the authors
notification.edit_decision_notify(request, project, edit_log)
return render(request, 'console/edit_complete.html',
@@ -670,7 +694,7 @@ def publish_submission(request, project_slug, *args, **kwargs):
'embargo_form': embargo_form})
-@permission_required('project.change_storagerequest', raise_exception=True)
+@console_permission_required('project.change_storagerequest')
def process_storage_response(request, storage_response_formset):
"""
Implement the response to a storage request.
@@ -699,7 +723,7 @@ def process_storage_response(request, storage_response_formset):
f"{notification.RESPONSE_ACTIONS[storage_request.response]}"))
-@permission_required('project.change_storagerequest', raise_exception=True)
+@console_permission_required('project.change_storagerequest')
def storage_requests(request):
"""
Page for listing and responding to project storage requests
@@ -718,11 +742,10 @@ def storage_requests(request):
queryset=StorageRequest.objects.filter(is_active=True))
return render(request, 'console/storage_requests.html',
- {'storage_response_formset': storage_response_formset,
- 'storage_requests_nav': True})
+ {'storage_response_formset': storage_response_formset})
-@permission_required('project.change_activeproject', raise_exception=True)
+@console_permission_required('project.change_activeproject')
def unsubmitted_projects(request):
"""
List of unsubmitted projects
@@ -731,10 +754,10 @@ def unsubmitted_projects(request):
'creation_datetime')
projects = paginate(request, projects, 50)
return render(request, 'console/unsubmitted_projects.html',
- {'projects': projects, 'unsubmitted_projects_nav': True})
+ {'projects': projects})
-@permission_required('project.change_publishedproject', raise_exception=True)
+@console_permission_required('project.change_publishedproject')
def published_projects(request):
"""
List of published projects
@@ -742,7 +765,7 @@ def published_projects(request):
projects = PublishedProject.objects.all().order_by('-publish_datetime')
projects = paginate(request, projects, 50)
return render(request, 'console/published_projects.html',
- {'projects': projects, 'published_projects_nav': True})
+ {'projects': projects})
@associated_task(PublishedProject, 'pid', read_only=True)
@@ -764,7 +787,74 @@ def send_files_to_gcp(pid):
project.gcp.save()
-@permission_required('project.change_publishedproject', raise_exception=True)
+@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)
+ upload_project_to_S3(project)
+ project.aws.sent_files = True
+ project.aws.finished_datetime = timezone.now()
+ if project.compressed_storage_size:
+ project.aws.sent_zip = True
+ project.aws.save()
+
+
+@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.
+ """
+ updated_policy = False
+ project = PublishedProject.objects.get(id=pid)
+ exists = check_s3_bucket_exists(project)
+ if exists:
+ bucket_name = get_bucket_name(project)
+ update_bucket_policy(project, bucket_name)
+ updated_policy = True
+ else:
+ updated_policy = False
+ return updated_policy
+
+
+@console_permission_required('project.change_publishedproject')
def manage_doi_request(request, project):
"""
Manage a request to register or update a Digital Object Identifier (DOI).
@@ -809,7 +899,7 @@ def manage_doi_request(request, project):
return message
-@permission_required('project.change_publishedproject', raise_exception=True)
+@console_permission_required('project.change_publishedproject')
def manage_published_project(request, project_slug, version):
"""
Manage a published project
@@ -892,6 +982,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_s3_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,13 +1048,15 @@ def manage_published_project(request, project_slug, version):
'topic_form': topic_form,
'deprecate_form': deprecate_form,
'has_credentials': has_credentials,
+ 'has_s3_credentials': has_s3_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,
'ro_tasks': ro_tasks,
'anonymous_url': anonymous_url,
'passphrase': passphrase,
- 'published_projects_nav': True,
'url_prefix': url_prefix,
'bulk_url_prefix': bulk_url_prefix,
'contact_form': contact_form,
@@ -971,7 +1068,7 @@ def manage_published_project(request, project_slug, version):
)
-@permission_required('project.change_publishedproject', raise_exception=True)
+@console_permission_required('project.change_publishedproject')
def gcp_bucket_management(request, project, user):
"""
Create the database object and cloud bucket if they do not exist, and send
@@ -1013,18 +1110,109 @@ def gcp_bucket_management(request, project, user):
send_files_to_gcp(project.id, verbose_name='GCP - {}'.format(project), creator=user)
-@permission_required('project.change_archivedproject', raise_exception=True)
+@console_permission_required('project.change_publishedproject')
+def aws_bucket_management(request, project, user):
+ """
+ Manage AWS S3 bucket for a project.
+
+ This function is responsible for 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.
+ """
+ is_private = True
+
+ if project.access_policy == AccessPolicy.OPEN:
+ is_private = False
+
+ bucket_name = get_bucket_name(project)
+
+ if not AWS.objects.filter(project=project).exists():
+ AWS.objects.create(
+ project=project, bucket_name=bucket_name, is_private=is_private
+ )
+
+ send_files_to_aws(project.id, verbose_name='AWS - {}'.format(project), creator=user)
+
+
+@console_permission_required('project.change_publishedproject')
+def cloud_mirrors(request):
+ """
+ Page for viewing the status of cloud mirrors.
+ """
+ projects = PublishedProject.objects.order_by('-publish_datetime')
+
+ group = request.GET.get('group', 'open')
+ if group == 'open':
+ projects = projects.filter(access_policy=AccessPolicy.OPEN)
+ else:
+ projects = projects.exclude(access_policy=AccessPolicy.OPEN)
+
+ cloud_platforms = []
+ if settings.GOOGLE_APPLICATION_CREDENTIALS:
+ cloud_platforms.append({
+ 'field_name': 'gcp',
+ 'name': 'GCP',
+ 'long_name': 'Google Cloud Platform',
+ })
+ if has_s3_credentials():
+ cloud_platforms.append({
+ 'field_name': 'aws',
+ 'name': 'AWS',
+ 'long_name': 'Amazon Web Services',
+ })
+
+ # Relevant fields for the status table (see
+ # templates/console/cloud_mirrors.html)
+ field_names = [platform['field_name'] for platform in cloud_platforms]
+ projects = projects.select_related(*field_names).only(
+ 'slug',
+ 'title',
+ 'version',
+ 'access_policy',
+ 'allow_file_downloads',
+ 'deprecated_files',
+ 'embargo_files_days',
+ *(f'{field}__is_private' for field in field_names),
+ *(f'{field}__sent_files' for field in field_names),
+ )
+
+ project_mirrors = {
+ project: [
+ getattr(project, field, None) for field in field_names
+ ] for project in projects
+ }
+
+ return render(request, 'console/cloud_mirrors.html', {
+ 'group': group,
+ 'cloud_platforms': cloud_platforms,
+ 'project_mirrors': project_mirrors,
+ })
+
+
+@console_permission_required('project.change_activeproject')
def archived_submissions(request):
"""
List of archived submissions
"""
- projects = ArchivedProject.objects.all().order_by('archive_datetime')
+ projects = ActiveProject.objects.filter(submission_status=SubmissionStatus.ARCHIVED
+ ).order_by('creation_datetime')
projects = paginate(request, projects, 50)
return render(request, 'console/archived_submissions.html',
- {'projects': projects, 'archived_projects_nav': True})
+ {'projects': projects})
-@permission_required('user.view_user', raise_exception=True)
+@console_permission_required('user.view_user')
def users(request, group='all'):
"""
List of users
@@ -1037,7 +1225,6 @@ def users(request, group='all'):
return render(request, 'console/users_admin.html', {
'admin_users': admin_users,
'group': group,
- 'user_nav': True,
})
elif group == 'active':
user_list = user_list.filter(is_active=True)
@@ -1046,10 +1233,10 @@ def users(request, group='all'):
users = paginate(request, user_list, 50)
- return render(request, 'console/users.html', {'users': users, 'group': group, 'user_nav': True})
+ return render(request, 'console/users.html', {'users': users, 'group': group})
-@permission_required('user.view_user', raise_exception=True)
+@console_permission_required('user.view_user')
def user_groups(request):
"""
List of all user groups
@@ -1057,10 +1244,10 @@ def user_groups(request):
groups = Group.objects.all().order_by('name')
for group in groups:
group.user_count = User.objects.filter(groups=group).count()
- return render(request, 'console/user_groups.html', {'groups': groups, 'user_nav': True, 'user_groups_nav': True})
+ return render(request, 'console/user_groups.html', {'groups': groups})
-@permission_required('user.view_user', raise_exception=True)
+@console_permission_required('user.view_user')
def user_group(request, group):
"""
Shows details of a user group, lists users in the group, lists permissions for the group
@@ -1071,11 +1258,11 @@ def user_group(request, group):
return render(
request,
'console/user_group.html',
- {'group': group, 'users': users, 'permissions': permissions, 'user_nav': True, 'user_groups_nav': True}
+ {'group': group, 'users': users, 'permissions': permissions}
)
-@permission_required('user.view_user', raise_exception=True)
+@console_permission_required('user.view_user')
def user_management(request, username):
"""
Admin page for managing an individual user account.
@@ -1113,9 +1300,11 @@ def user_management(request, username):
authors__user=user, submission_status=SubmissionStatus.UNSUBMITTED
).order_by("-creation_datetime")
projects["Submitted"] = ActiveProject.objects.filter(
- authors__user=user, submission_status__gt=SubmissionStatus.UNSUBMITTED
+ authors__user=user, submission_status__gt=SubmissionStatus.ARCHIVED
).order_by("-submission_datetime")
- projects['Archived'] = ArchivedProject.objects.filter(authors__user=user).order_by('-archive_datetime')
+ projects['Archived'] = ActiveProject.objects.filter(authors__user=user,
+ submission_status=SubmissionStatus.ARCHIVED
+ ).order_by('-creation_datetime')
projects['Published'] = PublishedProject.objects.filter(authors__user=user).order_by('-publish_datetime')
credentialing_app = CredentialApplication.objects.filter(user=user).order_by("application_datetime")
@@ -1133,7 +1322,7 @@ def user_management(request, username):
'gcp_info': gcp_info})
-@permission_required('user.view_user', raise_exception=True)
+@console_permission_required('user.view_user')
def users_search(request, group):
"""
Search user list.
@@ -1169,39 +1358,7 @@ def users_search(request, group):
raise Http404()
-@permission_required('user.view_user', raise_exception=True)
-def users_aws_access_list_json(request):
- """
- Generate JSON list of currently authorized AWS accounts.
-
- This is a temporary kludge to support an upcoming event (November
- 2022). Don't rely on this function; it will go away.
- """
- projects_datathon = [
- "mimiciv-2.2"
- ]
- published_projects = PublishedProject.objects.all()
- users_with_awsid = User.objects.filter(cloud_information__aws_id__isnull=False)
- datasets = {}
- datasets['datasets'] = []
- aws_id_pattern = r"\b\d{12}\b"
-
- for project in published_projects:
- dataset = {}
- project_name = project.slug + "-" + project.version
- if project_name in projects_datathon:
- dataset['name'] = project_name
- dataset['accounts'] = []
- for user in users_with_awsid:
- if can_view_project_files(project, user):
- if re.search(aws_id_pattern, user.cloud_information.aws_id):
- dataset['accounts'].append(user.cloud_information.aws_id)
- datasets['datasets'].append(dataset)
-
- return JsonResponse(datasets)
-
-
-@permission_required('user.change_credentialapplication', raise_exception=True)
+@console_permission_required('user.change_credentialapplication')
def known_references_search(request):
"""
Search credential applications and user list.
@@ -1229,7 +1386,7 @@ def known_references_search(request):
raise Http404()
-@permission_required('user.change_credentialapplication', raise_exception=True)
+@console_permission_required('user.change_credentialapplication')
def complete_credential_applications(request):
"""
Legacy page for processing credentialing applications.
@@ -1237,7 +1394,7 @@ def complete_credential_applications(request):
return redirect(credential_processing)
-@permission_required('user.change_credentialapplication', raise_exception=True)
+@console_permission_required('user.change_credentialapplication')
def complete_list_credentialed_people(request):
"""
Legacy page that displayed a list of all approved MIMIC users.
@@ -1245,7 +1402,7 @@ def complete_list_credentialed_people(request):
return redirect(credential_applications, "successful")
-@permission_required('user.change_credentialapplication', raise_exception=True)
+@console_permission_required('user.change_credentialapplication')
def process_credential_application(request, application_slug):
"""
Process a credential application. View details, advance to next stage,
@@ -1386,12 +1543,12 @@ def process_credential_application(request, application_slug):
{'application': application, 'app_user': application.user,
'intermediate_credential_form': intermediate_credential_form,
'credential_review_form': credential_review_form,
- 'processing_credentials_nav': True, 'page_title': page_title,
+ 'page_title': page_title,
'contact_cred_ref_form': contact_cred_ref_form,
'training_list': training})
-@permission_required('user.change_credentialapplication', raise_exception=True)
+@console_permission_required('user.change_credentialapplication')
def credential_processing(request):
"""
Process applications for credentialed access.
@@ -1435,11 +1592,10 @@ def credential_processing(request):
'personal_applications': personal_applications,
'reference_applications': reference_applications,
'response_applications': response_applications,
- 'final_applications': final_applications,
- 'processing_credentials_nav': True})
+ 'final_applications': final_applications})
-@permission_required('user.change_credentialapplication', raise_exception=True)
+@console_permission_required('user.change_credentialapplication')
def view_credential_application(request, application_slug):
"""
View a credential application in any status.
@@ -1459,10 +1615,10 @@ def view_credential_application(request, application_slug):
return render(request, 'console/view_credential_application.html',
{'application': application, 'app_user': application.user,
- 'form': form, 'past_credentials_nav': True, 'CredentialApplication': CredentialApplication})
+ 'form': form, 'CredentialApplication': CredentialApplication})
-@permission_required('user.change_credentialapplication', raise_exception=True)
+@console_permission_required('user.change_credentialapplication')
def credential_applications(request, status):
"""
Inactive credential applications. Split into successful and
@@ -1536,12 +1692,12 @@ def credential_applications(request, status):
pending_apps = paginate(request, pending_apps, 50)
return render(request, 'console/credential_applications.html',
- {'applications': all_successful_apps, 'past_credentials_nav': True,
+ {'applications': all_successful_apps,
'u_applications': unsuccessful_apps,
'p_applications': pending_apps})
-@permission_required('user.change_credentialapplication', raise_exception=True)
+@console_permission_required('user.change_credentialapplication')
def search_credential_applications(request):
"""
Search past credentialing applications.
@@ -1593,7 +1749,7 @@ def search_credential_applications(request):
return all_successful_apps, unsuccessful_apps, pending_apps
-@permission_required('user.change_credentialapplication', raise_exception=True)
+@console_permission_required('user.change_credentialapplication')
def credentialed_user_info(request, username):
try:
c_user = User.objects.get(username__iexact=username)
@@ -1605,7 +1761,7 @@ def credentialed_user_info(request, username):
'CredentialApplication': CredentialApplication})
-@permission_required('user.can_review_training', raise_exception=True)
+@console_permission_required('user.can_review_training')
def training_list(request, status):
"""
List all training applications.
@@ -1657,7 +1813,6 @@ def training_list(request, status):
'valid_count': valid_training.count(),
'expired_count': expired_training.count(),
'rejected_count': rejected_training.count(),
- 'training_nav': True,
},
)
@@ -1685,7 +1840,7 @@ def search_training_applications(request, display_training):
return display_training
-@permission_required('user.can_review_training', raise_exception=True)
+@console_permission_required('user.can_review_training')
def training_process(request, pk):
training = get_object_or_404(Training.objects.select_related('training_type', 'user__profile').get_review(), pk=pk)
@@ -1761,14 +1916,14 @@ def training_process(request, pk):
)
-@permission_required('user.can_review_training', raise_exception=True)
+@console_permission_required('user.can_review_training')
def training_detail(request, pk):
training = get_object_or_404(Training.objects.prefetch_related('training_type'), pk=pk)
return render(request, 'console/training_detail.html', {'training': training})
-@permission_required('notification.change_news', raise_exception=True)
+@console_permission_required('notification.change_news')
def news_console(request):
"""
List of news items
@@ -1776,10 +1931,10 @@ def news_console(request):
news_items = News.objects.all().order_by('-publish_datetime')
news_items = paginate(request, news_items, 50)
return render(request, 'console/news_console.html',
- {'news_items': news_items, 'news_nav': True})
+ {'news_items': news_items})
-@permission_required('notification.change_news', raise_exception=True)
+@console_permission_required('notification.change_news')
def news_add(request):
if request.method == 'POST':
form = forms.NewsForm(data=request.POST)
@@ -1791,11 +1946,10 @@ def news_add(request):
else:
form = forms.NewsForm()
- return render(request, 'console/news_add.html', {'form': form,
- 'news_nav': True})
+ return render(request, 'console/news_add.html', {'form': form})
-@permission_required('notification.change_news', raise_exception=True)
+@console_permission_required('notification.change_news')
def news_search(request):
"""
Filtered list of news items
@@ -1810,10 +1964,10 @@ def news_search(request):
raise Http404()
-@permission_required('notification.change_news', raise_exception=True)
-def news_edit(request, news_id):
+@console_permission_required('notification.change_news')
+def news_edit(request, news_slug):
try:
- news = News.objects.get(id=news_id)
+ news = News.objects.get(slug=news_slug)
except News.DoesNotExist:
raise Http404()
saved = False
@@ -1832,13 +1986,13 @@ def news_edit(request, news_id):
form = forms.NewsForm(instance=news)
response = render(request, 'console/news_edit.html', {'news': news,
- 'form': form, 'news_nav': True})
+ 'form': form})
if saved:
set_saved_fields_cookie(form, request.path, response)
return response
-@permission_required('project.can_edit_featured_content', raise_exception=True)
+@console_permission_required('project.can_edit_featured_content')
def featured_content(request):
"""
List of news items
@@ -1882,10 +2036,10 @@ def featured_content(request):
).order_by('featured')
return render(request, 'console/featured_content.html',
- {'featured_content': featured_content, 'featured_content_nav': True})
+ {'featured_content': featured_content})
-@permission_required('project.can_edit_featured_content', raise_exception=True)
+@console_permission_required('project.can_edit_featured_content')
def add_featured(request):
"""
List of news items
@@ -1914,11 +2068,10 @@ def add_featured(request):
return render(request, 'console/add_featured.html', {'title': title,
'projects': projects,
'form': form,
- 'valid_search': valid_search,
- 'featured_content_nav': True})
+ 'valid_search': valid_search})
-@permission_required('project.can_view_project_guidelines', raise_exception=True)
+@console_permission_required('project.can_view_project_guidelines')
def guidelines_review(request):
"""
Guidelines for reviewers.
@@ -1927,7 +2080,16 @@ def guidelines_review(request):
{'guidelines_review_nav': True})
-@permission_required('project.can_view_stats', raise_exception=True)
+@permission_required('training.can_view_course_guidelines', raise_exception=True)
+def guidelines_course(request):
+ """
+ Guidelines for course creators.
+ """
+ return render(request, 'console/guidelines_course.html',
+ {'guidelines_course_nav': True})
+
+
+@console_permission_required('project.can_view_stats')
def editorial_stats(request):
"""
Editorial stats for reviewers.
@@ -1966,11 +2128,11 @@ def editorial_stats(request):
except StatisticsError:
stats[y].append(None)
- return render(request, 'console/editorial_stats.html', {'stats_nav': True,
+ return render(request, 'console/editorial_stats.html', {
'submenu': 'editorial', 'stats': stats})
-@permission_required('project.can_view_stats', raise_exception=True)
+@console_permission_required('project.can_view_stats')
def credentialing_stats(request):
"""
Credentialing metrics.
@@ -2033,16 +2195,15 @@ def credentialing_stats(request):
stats[y]['time_to_decision'] = None
return render(request, 'console/credentialing_stats.html',
- {'stats_nav': True, 'submenu': 'credential',
+ {'submenu': 'credential',
'stats': stats})
-@permission_required('project.can_view_stats', raise_exception=True)
+@console_permission_required('project.can_view_stats')
def submission_stats(request):
stats = OrderedDict()
todays_date = datetime.today()
- all_projects = [PublishedProject.objects.filter(is_legacy=False), ActiveProject.objects.all(),
- ArchivedProject.objects.all()]
+ all_projects = [PublishedProject.objects.filter(is_legacy=False), ActiveProject.objects.all()]
cur_year = todays_date.year
cur_month = todays_date.month
@@ -2088,10 +2249,10 @@ def submission_stats(request):
pass
return render(request, 'console/submission_stats.html',
- {'stats_nav': True, 'submenu': 'submission', 'stats': stats})
+ {'submenu': 'submission', 'stats': stats})
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def download_credentialed_users(request):
"""
CSV create and download for database access.
@@ -2153,17 +2314,17 @@ def download_credentialed_users(request):
return response
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def project_access_manage(request, pid):
projects = PublishedProject.objects.prefetch_related('duasignature_set__user__profile')
c_project = get_object_or_404(projects, id=pid, access_policy=AccessPolicy.CREDENTIALED)
return render(request, 'console/project_access_manage.html', {
'c_project': c_project, 'project_members': c_project.duasignature_set.all(),
- 'project_access_logs_nav': True})
+ })
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def project_access_requests_list(request):
projects = PublishedProject.objects.filter(access_policy=AccessPolicy.CONTRIBUTOR_REVIEW).annotate(
access_requests_count=Count('data_access_requests')
@@ -2176,11 +2337,11 @@ def project_access_requests_list(request):
projects = paginate(request, projects, 50)
return render(request, 'console/project_access_requests_list.html', {
- 'access_requests_nav': True, 'projects': projects
+ 'projects': projects
})
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def project_access_requests_detail(request, pk):
project = get_object_or_404(PublishedProject, access_policy=AccessPolicy.CONTRIBUTOR_REVIEW, pk=pk)
access_requests = DataAccessRequest.objects.filter(project=project)
@@ -2193,18 +2354,18 @@ def project_access_requests_detail(request, pk):
access_requests = paginate(request, access_requests, 50)
return render(request, 'console/project_access_requests_detail.html', {
- 'access_requests_nav': True, 'project': project, 'access_requests': access_requests
+ 'project': project, 'access_requests': access_requests
})
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def access_request(request, pk):
access_request = get_object_or_404(DataAccessRequest, pk=pk)
return render(request, 'console/access_request.html', {'access_request': access_request})
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def project_access_logs(request):
c_projects = PublishedProject.objects.annotate(
log_count=Count('logs', filter=Q(logs__category=LogCategory.ACCESS)))
@@ -2220,11 +2381,11 @@ def project_access_logs(request):
c_projects = paginate(request, c_projects, 50)
return render(request, 'console/project_access_logs.html', {
- 'c_projects': c_projects, 'project_access_logs_nav': True,
+ 'c_projects': c_projects,
})
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def project_access_logs_detail(request, pid):
c_project = get_object_or_404(PublishedProject, id=pid)
logs = (
@@ -2249,11 +2410,11 @@ def project_access_logs_detail(request, pid):
return render(request, 'console/project_access_logs_detail.html', {
'c_project': c_project, 'logs': logs,
- 'project_access_logs_nav': True, 'user_filter_form': user_filter_form
+ 'user_filter_form': user_filter_form
})
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def download_project_accesses(request, pk):
headers = ['User', 'Email address', 'First access', 'Last access', 'Duration', 'Count']
@@ -2284,7 +2445,7 @@ def download_project_accesses(request, pk):
return response
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def user_access_logs(request):
users = (
User.objects.filter(is_active=True)
@@ -2304,11 +2465,11 @@ def user_access_logs(request):
users = paginate(request, users, 50)
return render(request, 'console/user_access_logs.html', {
- 'users': users, 'user_access_logs_nav': True,
+ 'users': users,
})
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def user_access_logs_detail(request, pid):
user = get_object_or_404(User, id=pid, is_active=True)
logs = (
@@ -2332,12 +2493,12 @@ def user_access_logs_detail(request, pid):
project_filter_form = ProjectFilterForm()
return render(request, 'console/user_access_logs_detail.html', {
- 'user': user, 'logs': logs, 'user_access_logs_nav': True,
+ 'user': user, 'logs': logs,
'project_filter_form': project_filter_form
})
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def download_user_accesses(request, pk):
headers = ['Project name', 'First access', 'Last access', 'Duration', 'Count']
@@ -2364,7 +2525,7 @@ def download_user_accesses(request, pk):
return response
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def gcp_signed_urls_logs(request):
projects = ActiveProject.objects.annotate(
log_count=Count('logs', filter=Q(logs__category=LogCategory.GCP)))
@@ -2376,11 +2537,11 @@ def gcp_signed_urls_logs(request):
projects = paginate(request, projects, 50)
return render(request, 'console/gcp_logs.html', {
- 'projects': projects, 'gcp_logs_nav': True,
+ 'projects': projects,
})
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def gcp_signed_urls_logs_detail(request, pk):
project = get_object_or_404(ActiveProject, pk=pk)
logs = project.logs.order_by('-creation_datetime').prefetch_related('project').annotate(
@@ -2390,11 +2551,10 @@ def gcp_signed_urls_logs_detail(request, pk):
return render(request, 'console/gcp_logs_detail.html', {
'project': project, 'logs': logs,
- 'gcp_logs_nav': True,
})
-@permission_required('project.can_view_access_logs', raise_exception=True)
+@console_permission_required('project.can_view_access_logs')
def download_signed_urls_logs(request, pk):
headers = ['User', 'Email address', 'First access', 'Last access', 'Duration', 'Data', 'Count']
@@ -2458,7 +2618,7 @@ def get_queryset(self):
return qs
-@permission_required('user.change_credentialapplication', raise_exception=True)
+@console_permission_required('user.change_credentialapplication')
def known_references(request):
"""
List all known references witht he option of removing the contact date
@@ -2484,10 +2644,11 @@ def known_references(request):
all_known_ref = paginate(request, all_known_ref, 50)
return render(request, 'console/known_references.html', {
- 'all_known_ref': all_known_ref, 'known_ref_nav': True})
+ 'all_known_ref': all_known_ref,
+ })
-@permission_required('physionet.view_redirect', raise_exception=True)
+@console_permission_required('redirects.view_redirect')
def view_redirects(request):
"""
Display a list of redirected URLs.
@@ -2496,10 +2657,10 @@ def view_redirects(request):
return render(
request,
'console/redirects.html',
- {'redirects': redirects, 'redirects_nav': True})
+ {'redirects': redirects})
-@permission_required('physionet.change_frontpagebutton', raise_exception=True)
+@console_permission_required('physionet.change_frontpagebutton')
def frontpage_buttons(request):
if request.method == 'POST':
@@ -2518,10 +2679,10 @@ def frontpage_buttons(request):
return render(
request,
'console/frontpage_button/index.html',
- {'frontpage_buttons': frontpage_buttons, 'frontpage_buttons_nav': True})
+ {'frontpage_buttons': frontpage_buttons})
-@permission_required('physionet.change_frontpagebutton', raise_exception=True)
+@console_permission_required('physionet.change_frontpagebutton')
def frontpage_button_add(request):
if request.method == 'POST':
frontpage_button_form = forms.FrontPageButtonForm(data=request.POST)
@@ -2539,7 +2700,7 @@ def frontpage_button_add(request):
)
-@permission_required('physionet.change_frontpagebutton', raise_exception=True)
+@console_permission_required('physionet.change_frontpagebutton')
def frontpage_button_edit(request, button_pk):
frontpage_button = get_object_or_404(FrontPageButton, pk=button_pk)
@@ -2562,7 +2723,7 @@ def frontpage_button_edit(request, button_pk):
)
-@permission_required('physionet.change_frontpagebutton', raise_exception=True)
+@console_permission_required('physionet.change_frontpagebutton')
def frontpage_button_delete(request, button_pk):
frontpage_button = get_object_or_404(FrontPageButton, pk=button_pk)
if request.method == 'POST':
@@ -2572,7 +2733,7 @@ def frontpage_button_delete(request, button_pk):
return HttpResponseRedirect(reverse('frontpage_buttons'))
-@permission_required('physionet.change_staticpage', raise_exception=True)
+@console_permission_required('physionet.change_staticpage')
def static_pages(request):
if request.method == 'POST':
up = request.POST.get('up')
@@ -2590,10 +2751,10 @@ def static_pages(request):
return render(
request,
'console/static_page/index.html',
- {'pages': pages, 'static_pages_nav': True})
+ {'pages': pages})
-@permission_required('physionet.change_staticpage', raise_exception=True)
+@console_permission_required('physionet.change_staticpage')
def static_page_add(request):
if request.method == 'POST':
static_page_form = forms.StaticPageForm(data=request.POST)
@@ -2611,7 +2772,7 @@ def static_page_add(request):
)
-@permission_required('physionet.change_staticpage', raise_exception=True)
+@console_permission_required('physionet.change_staticpage')
def static_page_edit(request, page_pk):
static_page = get_object_or_404(StaticPage, pk=page_pk)
@@ -2631,7 +2792,7 @@ def static_page_edit(request, page_pk):
)
-@permission_required('physionet.change_staticpage', raise_exception=True)
+@console_permission_required('physionet.change_staticpage')
def static_page_delete(request, page_pk):
static_page = get_object_or_404(StaticPage, pk=page_pk)
if request.method == 'POST':
@@ -2641,7 +2802,7 @@ def static_page_delete(request, page_pk):
return HttpResponseRedirect(reverse('static_pages'))
-@permission_required('physionet.change_staticpage', raise_exception=True)
+@console_permission_required('physionet.change_staticpage')
def static_page_sections(request, page_pk):
static_page = get_object_or_404(StaticPage, pk=page_pk)
if request.method == 'POST':
@@ -2666,11 +2827,11 @@ def static_page_sections(request, page_pk):
return render(
request,
'console/static_page_sections.html',
- {'sections': sections, 'page': static_page, 'section_form': section_form, 'static_pages_nav': True},
+ {'sections': sections, 'page': static_page, 'section_form': section_form},
)
-@permission_required('physionet.change_staticpage', raise_exception=True)
+@console_permission_required('physionet.change_staticpage')
def static_page_sections_delete(request, page_pk, section_pk):
static_page = get_object_or_404(StaticPage, pk=page_pk)
if request.method == 'POST':
@@ -2681,7 +2842,7 @@ def static_page_sections_delete(request, page_pk, section_pk):
return redirect('static_page_sections', page_pk=static_page.pk)
-@permission_required('physionet.change_staticpage', raise_exception=True)
+@console_permission_required('physionet.change_staticpage')
def static_page_sections_edit(request, page_pk, section_pk):
static_page = get_object_or_404(StaticPage, pk=page_pk)
section = get_object_or_404(Section, static_page=static_page, pk=section_pk)
@@ -2696,11 +2857,11 @@ def static_page_sections_edit(request, page_pk, section_pk):
return render(
request,
'console/static_page_sections_edit.html',
- {'section_form': section_form, 'static_pages_nav': True, 'page': static_page, 'section': section},
+ {'section_form': section_form, 'page': static_page, 'section': section},
)
-@permission_required('project.add_license', raise_exception=True)
+@console_permission_required('project.add_license')
def license_list(request):
if request.method == 'POST':
license_form = forms.LicenseForm(data=request.POST)
@@ -2719,11 +2880,11 @@ def license_list(request):
return render(
request,
'console/license_list.html',
- {'license_nav': True, 'licenses': licenses, 'license_form': license_form}
+ {'licenses': licenses, 'license_form': license_form}
)
-@permission_required('project.add_license', raise_exception=True)
+@console_permission_required('project.add_license')
def license_detail(request, pk):
license = get_object_or_404(License, pk=pk)
@@ -2741,11 +2902,11 @@ def license_detail(request, pk):
return render(
request,
'console/license_detail.html',
- {'license_nav': True, 'license': license, 'license_form': license_form}
+ {'license': license, 'license_form': license_form}
)
-@permission_required('project.add_license', raise_exception=True)
+@console_permission_required('project.add_license')
def license_delete(request, pk):
if request.method == 'POST':
license = get_object_or_404(License, pk=pk)
@@ -2754,7 +2915,7 @@ def license_delete(request, pk):
return redirect('license_list')
-@permission_required('project.add_license', raise_exception=True)
+@console_permission_required('project.add_license')
def license_new_version(request, pk):
license = get_object_or_404(License, pk=pk)
@@ -2775,11 +2936,11 @@ def license_new_version(request, pk):
return render(
request,
'console/license_new_version.html',
- {'license_nav': True, 'license': license, 'license_form': license_form}
+ {'license': license, 'license_form': license_form}
)
-@permission_required('project.add_dua', raise_exception=True)
+@console_permission_required('project.add_dua')
def dua_list(request):
if request.method == 'POST':
dua_form = forms.DUAForm(data=request.POST)
@@ -2795,10 +2956,10 @@ def dua_list(request):
duas = DUA.objects.order_by('access_policy', 'name')
duas = paginate(request, duas, 20)
- return render(request, 'console/dua_list.html', {'dua_nav': True, 'duas': duas, 'dua_form': dua_form})
+ return render(request, 'console/dua_list.html', {'duas': duas, 'dua_form': dua_form})
-@permission_required('project.add_dua', raise_exception=True)
+@console_permission_required('project.add_dua')
def dua_detail(request, pk):
dua = get_object_or_404(DUA, pk=pk)
@@ -2813,10 +2974,10 @@ def dua_detail(request, pk):
else:
dua_form = forms.DUAForm(instance=dua)
- return render(request, 'console/dua_detail.html', {'dua_nav': True, 'dua': dua, 'dua_form': dua_form})
+ return render(request, 'console/dua_detail.html', {'dua': dua, 'dua_form': dua_form})
-@permission_required('project.add_dua', raise_exception=True)
+@console_permission_required('project.add_dua')
def dua_delete(request, pk):
if request.method == 'POST':
dua = get_object_or_404(DUA, pk=pk)
@@ -2825,7 +2986,7 @@ def dua_delete(request, pk):
return redirect("dua_list")
-@permission_required('project.add_dua', raise_exception=True)
+@console_permission_required('project.add_dua')
def dua_new_version(request, pk):
dua = get_object_or_404(DUA, pk=pk)
@@ -2843,10 +3004,10 @@ def dua_new_version(request, pk):
dua_data['version'] = None
dua_form = forms.DUAForm(initial=dua_data)
- return render(request, 'console/dua_new_version.html', {'dua_nav': True, 'dua': dua, 'dua_form': dua_form})
+ return render(request, 'console/dua_new_version.html', {'dua': dua, 'dua_form': dua_form})
-@permission_required('project.add_codeofconduct', raise_exception=True)
+@console_permission_required('project.add_codeofconduct')
def code_of_conduct_list(request):
if request.method == 'POST':
code_of_conduct_form = forms.CodeOfConductForm(data=request.POST)
@@ -2866,14 +3027,13 @@ def code_of_conduct_list(request):
request,
'console/code_of_conduct_list.html',
{
- 'code_of_conduct_nav': True,
'code_of_conducts': code_of_conducts,
'code_of_conduct_form': code_of_conduct_form,
},
)
-@permission_required('project.add_codeofconduct', raise_exception=True)
+@console_permission_required('project.add_codeofconduct')
def code_of_conduct_detail(request, pk):
code_of_conduct = get_object_or_404(CodeOfConduct, pk=pk)
if request.method == 'POST':
@@ -2891,14 +3051,13 @@ def code_of_conduct_detail(request, pk):
request,
'console/code_of_conduct_detail.html',
{
- 'code_of_conduct_nav': True,
'code_of_conduct': code_of_conduct,
'code_of_conduct_form': code_of_conduct_form,
},
)
-@permission_required('project.add_codeofconduct', raise_exception=True)
+@console_permission_required('project.add_codeofconduct')
def code_of_conduct_delete(request, pk):
if request.method == 'POST':
code_of_conduct = get_object_or_404(CodeOfConduct, pk=pk)
@@ -2907,7 +3066,7 @@ def code_of_conduct_delete(request, pk):
return redirect("code_of_conduct_list")
-@permission_required('project.add_codeofconduct', raise_exception=True)
+@console_permission_required('project.add_codeofconduct')
def code_of_conduct_new_version(request, pk):
code_of_conduct = get_object_or_404(CodeOfConduct, pk=pk)
if request.method == 'POST':
@@ -2928,14 +3087,13 @@ def code_of_conduct_new_version(request, pk):
request,
'console/code_of_conduct_new_version.html',
{
- 'code_of_conduct_nav': True,
'code_of_conduct': code_of_conduct,
'code_of_conduct_form': code_of_conduct_form,
},
)
-@permission_required('project.add_codeofconduct', raise_exception=True)
+@console_permission_required('project.add_codeofconduct')
def code_of_conduct_activate(request, pk):
CodeOfConduct.objects.filter(is_active=True).update(is_active=False)
@@ -2948,7 +3106,7 @@ def code_of_conduct_activate(request, pk):
return redirect("code_of_conduct_list")
-@permission_required('user.view_all_events', raise_exception=True)
+@console_permission_required('user.view_all_events')
def event_active(request):
"""
List of events
@@ -2962,7 +3120,7 @@ def event_active(request):
})
-@permission_required('user.view_all_events', raise_exception=True)
+@console_permission_required('user.view_all_events')
def event_archive(request):
"""
List of archived events
@@ -2976,7 +3134,7 @@ def event_archive(request):
})
-@permission_required('user.view_all_events', raise_exception=True)
+@console_permission_required('user.view_all_events')
def event_management(request, event_slug):
"""
Admin page for managing an individual Event.
@@ -2985,44 +3143,90 @@ def event_management(request, event_slug):
# handle the add dataset form(s)
if request.method == "POST":
- if 'add-event-dataset' in request.POST.keys():
+ 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 active_datasets.count() == 0:
event_dataset_form.instance.event = selected_event
event_dataset_form.save()
- messages.success(request, "The dataset has been added to the event.")
+ messages.success(
+ request, "The dataset has been added to the event."
+ )
else:
- messages.error(request, "The dataset has already been added to the event.")
+ messages.error(
+ request, "The dataset has already been added to the event."
+ )
else:
messages.error(request, event_dataset_form.errors)
- return redirect('event_management', event_slug=event_slug)
- elif 'remove-event-dataset' in request.POST.keys():
- event_dataset_id = request.POST['remove-event-dataset']
+ return redirect("event_management", event_slug=event_slug)
+ elif "remove-event-dataset" in request.POST.keys():
+ event_dataset_id = request.POST["remove-event-dataset"]
event_dataset = get_object_or_404(EventDataset, pk=event_dataset_id)
event_dataset.revoke_dataset_access()
messages.success(request, "The dataset has been removed from the event.")
- return redirect('event_management', event_slug=event_slug)
+ return redirect("event_management", event_slug=event_slug)
else:
event_dataset_form = EventDatasetForm()
+ participants = selected_event.participants.all()
+ pending_applications = selected_event.applications.filter(
+ status=EventApplication.EventApplicationStatus.WAITLISTED
+ )
+ rejected_applications = selected_event.applications.filter(
+ status=EventApplication.EventApplicationStatus.NOT_APPROVED
+ )
+ withdrawn_applications = selected_event.applications.filter(
+ status=EventApplication.EventApplicationStatus.WITHDRAWN
+ )
+
event_datasets = selected_event.datasets.filter(is_active=True)
+ applicant_info = [
+ {
+ "id": "participants",
+ "title": "Total participants:",
+ "count": len(participants),
+ "objects": participants,
+ },
+ {
+ "id": "pending_applications",
+ "title": "Pending applications:",
+ "count": len(pending_applications),
+ "objects": pending_applications,
+ },
+ {
+ "id": "rejected_applications",
+ "title": "Rejected applications:",
+ "count": len(rejected_applications),
+ "objects": rejected_applications,
+ },
+ {
+ "id": "withdrawn_applications",
+ "title": "Withdrawn applications:",
+ "count": len(withdrawn_applications),
+ "objects": withdrawn_applications,
+ },
+ ]
+
return render(
request,
- 'console/event_management.html',
+ "console/event_management.html",
{
- 'event': selected_event,
- 'event_dataset_form': event_dataset_form,
- 'event_datasets': event_datasets,
- })
+ "event": selected_event,
+ "event_dataset_form": event_dataset_form,
+ "event_datasets": event_datasets,
+ "applicant_info": applicant_info,
+ "participants": participants,
+ },
+ )
-@permission_required('events.add_eventagreement', raise_exception=True)
+@console_permission_required('events.add_eventagreement')
def event_agreement_list(request):
if request.method == 'POST':
event_agreement_form = EventAgreementForm(data=request.POST)
@@ -3043,14 +3247,13 @@ def event_agreement_list(request):
request,
'console/event_agreement_list.html',
{
- 'event_agreement_nav': True,
'event_agreements': event_agreements,
'event_agreement_form': event_agreement_form
}
)
-@permission_required('events.add_eventagreement', raise_exception=True)
+@console_permission_required('events.add_eventagreement')
def event_agreement_new_version(request, pk):
event_agreement = get_object_or_404(EventAgreement, pk=pk)
@@ -3073,14 +3276,13 @@ def event_agreement_new_version(request, pk):
request,
'console/event_agreement_new_version.html',
{
- 'event_agreement_nav': True,
'event_agreement': event_agreement,
'event_agreement_form': event_agreement_form
}
)
-@permission_required('events.add_eventagreement', raise_exception=True)
+@console_permission_required('events.add_eventagreement')
def event_agreement_detail(request, pk):
event_agreement = get_object_or_404(EventAgreement, pk=pk)
@@ -3099,14 +3301,13 @@ def event_agreement_detail(request, pk):
request,
'console/event_agreement_detail.html',
{
- 'event_agreement_nav': True,
'event_agreement': event_agreement,
'event_agreement_form': event_agreement_form
}
)
-@permission_required('events.add_eventagreement', raise_exception=True)
+@console_permission_required('events.add_eventagreement')
def event_agreement_delete(request, pk):
if request.method == 'POST':
event_agreement = get_object_or_404(EventAgreement, pk=pk)
diff --git a/physionet-django/events/templates/events/event_applications.html b/physionet-django/events/templates/events/event_applications.html
new file mode 100644
index 0000000000..e501081eb2
--- /dev/null
+++ b/physionet-django/events/templates/events/event_applications.html
@@ -0,0 +1,27 @@
+{% load participation_status %}
+
+
+
+
+
+
+ Username
+ Full name
+ Email
+ Credentialed
+
+
+
+ {% for application in info.objects %}
+
+ {{ application.user.username }}
+ {{ application.user.get_full_name }}
+ {{ application.user.email }}
+ {{ application.user.is_credentialed }}
+
+ {% endfor %}
+
+
+
+
+
diff --git a/physionet-django/events/templates/events/event_entries.html b/physionet-django/events/templates/events/event_entries.html
index 60d0135bd0..3fc91e2ce1 100644
--- a/physionet-django/events/templates/events/event_entries.html
+++ b/physionet-django/events/templates/events/event_entries.html
@@ -1,30 +1,35 @@
-
+{% load participation_status %}
+
diff --git a/physionet-django/events/templates/events/event_home.html b/physionet-django/events/templates/events/event_home.html
index bd62b980eb..ec33a693d2 100644
--- a/physionet-django/events/templates/events/event_home.html
+++ b/physionet-django/events/templates/events/event_home.html
@@ -85,7 +85,10 @@ {{ event.title }}
{% if event.host == user %}
Share the class code: {{ url_prefix }}{% url 'event_detail' event.slug %}
- View participants
+ View participants
+ Pending Applications
+ Rejected Applications
+ Withdrawn Applications
Edit Event
{% endif %}
@@ -99,23 +102,24 @@ {{ event.title }}
- {% if events_active %}
- {% for event in events_active %}
-
+ {% for event in events_active %}
+ {% for info in event_details|get_applicant_info:event.id %}
+
- {% include 'events/event_entries.html' %}
+ {% include 'events/event_applications.html' %}
{% endfor %}
- {% endif %}
+ {% endfor %}
+
@@ -153,7 +157,7 @@
{{ event.title }}
{% if event.host == user %}
Share the class code: {{ url_prefix }}{% url 'event_detail' event.slug %}
-
View participants
+
View participants
Edit Event
{% endif %}
@@ -169,20 +173,22 @@
{{ event.title }}
{% if events_past %}
{% for event in events_past %}
-
+ {% for info in event_details|get_applicant_info:event.id %}
+
- {% include 'events/event_entries.html' %}
+ {% include 'events/event_applications.html' %}
{% endfor %}
+ {% endfor %}
{% endif %}
{% endblock %}
diff --git a/physionet-django/events/templatetags/participation_status.py b/physionet-django/events/templatetags/participation_status.py
index b1e4d3b91b..d67bbfd797 100644
--- a/physionet-django/events/templatetags/participation_status.py
+++ b/physionet-django/events/templatetags/participation_status.py
@@ -1,25 +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_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 f21eb69797..3c2f256560 100644
--- a/physionet-django/events/views.py
+++ b/physionet-django/events/views.py
@@ -79,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()))
@@ -99,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():
@@ -114,53 +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 = Event.objects.all().prefetch_related(
+ "participants",
+ "applications",
+ )
+
+ event_details = {}
+ for event in events:
+ applications = event.applications.all()
+ pending_applications = [
+ application
+ for application in applications
+ if application.status == EventApplication.EventApplicationStatus.WAITLISTED
+ ]
+ rejected_applications = [
+ application
+ for application in applications
+ if application.status == EventApplication.EventApplicationStatus.NOT_APPROVED
+ ]
+ withdrawn_applications = [
+ application
+ for application in applications
+ if application.status == EventApplication.EventApplicationStatus.WITHDRAWN
+ ]
+
+ event_details[event.id] = [
+ {
+ "id": "participants",
+ "title": "Total participants:",
+ "count": len(event.participants.all()),
+ "objects": event.participants.all(),
+ },
+ {
+ "id": "pending_applications",
+ "title": "Pending applications:",
+ "count": len(pending_applications),
+ "objects": pending_applications,
+ },
+ {
+ "id": "rejected_applications",
+ "title": "Rejected applications:",
+ "count": len(rejected_applications),
+ "objects": rejected_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,
- })
+ 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
diff --git a/physionet-django/lightwave/urls.py b/physionet-django/lightwave/urls.py
index c3d2211f49..433d754f91 100644
--- a/physionet-django/lightwave/urls.py
+++ b/physionet-django/lightwave/urls.py
@@ -1,3 +1,5 @@
+import shutil
+
from django.urls import path
from lightwave import views
@@ -17,3 +19,11 @@
'project_slug': 'SHuKI1APLrwWCqxSQnSk',
'_user_': 'rgmark',
}
+TEST_CASES = {
+ 'lightwave_server': {
+ '_skip_': lambda: (shutil.which('sandboxed-lightwave') is None),
+ },
+ 'lightwave_project_server': {
+ '_skip_': lambda: (shutil.which('sandboxed-lightwave') is None),
+ },
+}
diff --git a/physionet-django/notification/fixtures/demo-notification.json b/physionet-django/notification/fixtures/demo-notification.json
index 4a236286db..d69b6d7c06 100644
--- a/physionet-django/notification/fixtures/demo-notification.json
+++ b/physionet-django/notification/fixtures/demo-notification.json
@@ -6,7 +6,8 @@
"title": "New Platform Nearing Completion",
"content": "
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.
",
"publish_datetime": "2018-07-05T19:47:15.285Z",
- "url": "https://github.com/MIT-LCP/physionet-build/"
+ "url": "https://github.com/MIT-LCP/physionet-build/",
+ "slug": "new-platform"
}
},
{
@@ -16,7 +17,8 @@
"title": "Cloud Migration",
"content": "
PhysioNet may potentially partner with Google and host its data on Google Cloud Platform.
",
"publish_datetime": "2018-08-10T19:50:31.362Z",
- "url": ""
+ "url": "",
+ "slug": "cloud-migration"
}
},
{
@@ -26,7 +28,8 @@
"title": "Preparing New Challenges",
"content": "
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/migrations/0008_news_slug.py b/physionet-django/notification/migrations/0008_news_slug.py
new file mode 100644
index 0000000000..97af0e9f5e
--- /dev/null
+++ b/physionet-django/notification/migrations/0008_news_slug.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.1.10 on 2023-11-29 19:39
+
+from django.db import migrations, models
+
+
+def set_default_slugs(apps, schema_editor):
+ News = apps.get_model('notification', 'News')
+ for news in News.objects.all():
+ news.slug = str(news.id)
+ news.save()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("notification", "0007_auto_20220221_0332"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="news",
+ name="slug",
+ field=models.SlugField(max_length=100, null=True),
+ ),
+ migrations.RunPython(set_default_slugs, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/physionet-django/notification/migrations/0009_alter_news_slug.py b/physionet-django/notification/migrations/0009_alter_news_slug.py
new file mode 100644
index 0000000000..4ed4afab4a
--- /dev/null
+++ b/physionet-django/notification/migrations/0009_alter_news_slug.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.10 on 2023-11-29 20:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("notification", "0008_news_slug"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="news",
+ name="slug",
+ field=models.SlugField(max_length=100, unique=True),
+ preserve_default=False,
+ ),
+ ]
diff --git a/physionet-django/notification/migrations/0010_news_link_all_versions.py b/physionet-django/notification/migrations/0010_news_link_all_versions.py
new file mode 100644
index 0000000000..f79b721590
--- /dev/null
+++ b/physionet-django/notification/migrations/0010_news_link_all_versions.py
@@ -0,0 +1,20 @@
+# Generated by Django 4.1.10 on 2024-01-30 20:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("notification", "0009_alter_news_slug"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="news",
+ name="link_all_versions",
+ field=models.BooleanField(
+ default=False,
+ help_text="Check this to link the news item to all versions of the selected project",
+ ),
+ ),
+ ]
diff --git a/physionet-django/notification/models.py b/physionet-django/notification/models.py
index af876f87c1..5a2f88a0da 100644
--- a/physionet-django/notification/models.py
+++ b/physionet-django/notification/models.py
@@ -7,15 +7,26 @@
class News(models.Model):
"""
+ Model to record news and announcements.
"""
title = models.CharField(max_length=100)
content = SafeHTMLField()
publish_datetime = models.DateTimeField(auto_now_add=True)
url = models.URLField(default='', blank=True)
- project = models.ForeignKey('project.PublishedProject', null=True, blank=True,
- on_delete=models.SET_NULL, related_name='news')
+ project = models.ForeignKey(
+ 'project.PublishedProject',
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='news'
+ )
+ link_all_versions = models.BooleanField(
+ default=False,
+ help_text='Check this to link the news item to all versions of the selected project'
+ )
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/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/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 @@
{{ year }} News
{% for news in news_pieces %}
{% include "notification/news_content.html" %}
diff --git a/physionet-django/notification/templates/notification/news_item.html b/physionet-django/notification/templates/notification/news_item.html
index ed5b4afb99..4494e46cb5 100644
--- a/physionet-django/notification/templates/notification/news_item.html
+++ b/physionet-django/notification/templates/notification/news_item.html
@@ -2,10 +2,7 @@
{% load static %}
-{% block title %}
-{{ year }} News
-{% endblock %}
-
+{% block title %}{{ news.title }}{% endblock %}
{% block content %}
diff --git a/physionet-django/notification/urls.py b/physionet-django/notification/urls.py
index 166f9741ee..7e1a1b3833 100644
--- a/physionet-django/notification/urls.py
+++ b/physionet-django/notification/urls.py
@@ -6,7 +6,7 @@
urlpatterns = [
path('news/', views.news, name='news'),
path('news/
/', views.news_year, name='news_year'),
- path('news/post/', views.news_by_id, name='news_by_id'),
+ path('news/post/', views.news_by_slug, name='news_by_slug'),
path('feed.xml', views.news_rss, name='news_rss'),
]
@@ -14,4 +14,5 @@
TEST_DEFAULTS = {
'year': '2018',
'news_id': '1',
+ 'news_slug': 'cloud-migration',
}
diff --git a/physionet-django/notification/utility.py b/physionet-django/notification/utility.py
index 4960b58e22..83697e2603 100644
--- a/physionet-django/notification/utility.py
+++ b/physionet-django/notification/utility.py
@@ -1023,3 +1023,38 @@ 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)
+
+
+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/notification/views.py b/physionet-django/notification/views.py
index eabc268762..39d90b894d 100644
--- a/physionet-django/notification/views.py
+++ b/physionet-django/notification/views.py
@@ -44,12 +44,13 @@ def news_year(request, year):
{'year': year, 'news_pieces': news_pieces,
'news_years': news_years})
-def news_by_id(request, news_id, max_items=20):
+
+def news_by_slug(request, news_slug, max_items=20):
"""
Get a specific news item
"""
try:
- news = News.objects.get(id=news_id)
+ news = News.objects.get(slug=news_slug)
# The year range of all the PN news in existence.
minmax = News.objects.all().aggregate(min=Min('publish_datetime'),
max=Max('publish_datetime'))
@@ -60,6 +61,7 @@ def news_by_id(request, news_id, max_items=20):
except News.DoesNotExist:
raise Http404()
+
def news_rss(request, max_items=100):
news_pieces = News.objects.order_by('-publish_datetime')[:max_items]
feed_date = news_pieces[0].publish_datetime
diff --git a/physionet-django/physionet/settings/base.py b/physionet-django/physionet/settings/base.py
index ca7cb457f7..4035feb03d 100644
--- a/physionet-django/physionet/settings/base.py
+++ b/physionet-django/physionet/settings/base.py
@@ -51,11 +51,13 @@
'ckeditor',
# 'django_cron',
+ 'django_q',
'background_task',
'rest_framework',
'oauth2_provider',
'corsheaders',
+ 'training',
'user',
'project',
'console',
@@ -122,6 +124,7 @@
'sso.context_processors.sso_enabled',
'physionet.context_processors.cloud_research_environments_config',
],
+ 'debug': DEBUG,
},
},
]
@@ -173,6 +176,23 @@
USE_TZ = True
+# Configuration for Django Q2 (django_q2)
+# Django Q2 is a task queue, scheduler, worker application
+# Django ORM is the simplest backend for the message broker,
+# but it "is not best for a high-traffic setup".
+# https://django-q2.readthedocs.io/en/master/configure.html
+Q_CLUSTER = {
+ 'name': 'Django_ORM',
+ 'workers': 4,
+ 'timeout': 120,
+ 'retry': 600,
+ 'max_attempts': 5,
+ 'queue_limit': 100,
+ 'bulk': 10,
+ 'orm': 'default',
+ 'label': 'Django Q2',
+}
+
# Django background tasks max attempts
MAX_ATTEMPTS = 5
@@ -225,6 +245,23 @@
# 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
+if config('AWS_SHARED_CREDENTIALS_FILE', default=None):
+ AWS_SHARED_CREDENTIALS_FILE = os.path.expanduser(config('AWS_SHARED_CREDENTIALS_FILE'))
+ os.environ['AWS_SHARED_CREDENTIALS_FILE'] = AWS_SHARED_CREDENTIALS_FILE
+else:
+ AWS_SHARED_CREDENTIALS_FILE = None
+
+AWS_PROFILE = config('AWS_PROFILE', default=None)
+
+AWS_ACCOUNT_ID = config('AWS_ACCOUNT_ID', default=None)
+
+# Bucket name for the S3 bucket containing the open access data
+S3_OPEN_ACCESS_BUCKET = config('S3_OPEN_ACCESS_BUCKET', default=None)
+
+# Bucket name to store logs and metrics related to project usage.
+S3_SERVER_ACCESS_LOG_BUCKET = config('S3_SERVER_ACCESS_LOG_BUCKET', default=None)
+
# 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)
@@ -610,6 +647,7 @@ class StorageTypes:
# User model configurable settings
MAX_EMAILS_PER_USER = config('MAX_EMAILS_PER_USER', cast=int, default=10)
+MAX_SUBMITTABLE_PROJECTS = config('MAX_SUBMITTABLE_PROJECTS', cast=int, default=10)
# Updating to Django to 3.2 requires DEFAULT_AUTO_FIELD to be specified
# Starting at 3.2, new projects are generated with DEFAULT_AUTO_FIELD set to BigAutoField
@@ -622,3 +660,11 @@ class StorageTypes:
# Django configuration for file upload (see https://docs.djangoproject.com/en/4.2/ref/settings/)
DATA_UPLOAD_MAX_NUMBER_FILES = config('DATA_UPLOAD_MAX_NUMBER_FILES', cast=int, default=1000)
DATA_UPLOAD_MAX_MEMORY_SIZE = config('DATA_UPLOAD_MAX_MEMORY_SIZE', cast=int, default=2621440)
+
+# Emails
+PROJECT_EDITOR_EMAIL = config('PROJECT_EDITOR_EMAIL', default='')
+
+ALLOWED_ACCESS_POLICIES = config(
+ 'ALLOWED_ACCESS_POLICIES',
+ default='OPEN,RESTRICTED,CREDENTIALED,CONTRIBUTOR_REVIEW'
+).split(',')
diff --git a/physionet-django/physionet/urls.py b/physionet-django/physionet/urls.py
index 2cabf5d9aa..7b76aa0e1e 100644
--- a/physionet-django/physionet/urls.py
+++ b/physionet-django/physionet/urls.py
@@ -24,6 +24,8 @@
path('console/', include('console.urls')),
# user app
path('', include('user.urls')),
+ # training app
+ path('', include('training.urls')),
# project app
path('projects/', include('project.urls')),
# events
@@ -108,7 +110,8 @@
'dua_slug': 'physionet-credentialed-health-data-dua',
'event_slug': 'iLII4L9jSDFh',
'license_slug': 'open-data-commons-attribution-license-v10',
- 'static_url': 'publish'
+ 'static_url': 'publish',
+ 'news_slug': 'cloud-migration',
}
TEST_CASES = {
'lightwave_server_compat': {
diff --git a/physionet-django/project/cloud/s3.py b/physionet-django/project/cloud/s3.py
new file mode 100644
index 0000000000..97ac6e3bf8
--- /dev/null
+++ b/physionet-django/project/cloud/s3.py
@@ -0,0 +1,778 @@
+import boto3
+import botocore
+import re
+import os
+import json
+from django.conf import settings
+from project.models import PublishedProject, AccessPolicy, AWS
+from user.models import User
+from project.authorization.access import can_view_project_files
+
+
+# Manage AWS buckets and objects
+def has_S3_open_data_bucket_name():
+ """
+ Check if AWS credentials (AWS_PROFILE) have been set in
+ the project's settings.
+
+ Returns:
+ bool: True if AWS_PROFILE is set, False otherwise.
+ """
+ return bool(settings.S3_OPEN_ACCESS_BUCKET)
+
+
+def has_s3_credentials():
+ """
+ Check if AWS credentials (AWS_PROFILE) have been set in
+ the project's settings.
+
+ Returns:
+ bool: True if AWS_PROFILE is set, False otherwise.
+ """
+ return all([
+ settings.AWS_PROFILE,
+ settings.AWS_ACCOUNT_ID,
+ settings.S3_OPEN_ACCESS_BUCKET,
+ settings.S3_SERVER_ACCESS_LOG_BUCKET,
+ ])
+
+
+def files_sent_to_S3(project):
+ """
+ Get information about project files sent to Amazon S3
+ for a project.
+
+ Tries to access the AWS instance associated with the
+ project to retrieve sent file information.
+ Returns the information or None if it's not available.
+ """
+ try:
+ aws_instance = project.aws
+ sent_files_info = aws_instance.sent_files
+ except AWS.DoesNotExist:
+ sent_files_info = None
+ return sent_files_info
+
+
+def create_s3_client():
+ """
+ Create and return an AWS S3 client object if
+ AWS_PROFILE is not None.
+
+ 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.
+
+ Raises:
+ botocore.exceptions.NoCredentialsError: If S3 credentials are undefined.
+ """
+ if has_s3_credentials():
+ session = boto3.Session(
+ profile_name=settings.AWS_PROFILE
+ )
+ s3 = session.client("s3", region_name="us-east-1")
+ return s3
+ raise botocore.exceptions.NoCredentialsError("S3 credentials are undefined.")
+
+
+def create_s3_resource():
+ """
+ Creates and returns an AWS S3 resource if valid credentials are available.
+
+ Returns:
+ boto3.resources.base.ServiceResource:
+ An S3 resource if credentials are valid.
+
+ Raises:
+ botocore.exceptions.NoCredentialsError: If S3 credentials are undefined.
+ """
+ if has_s3_credentials():
+ session = boto3.Session(
+ profile_name=settings.AWS_PROFILE
+ )
+ s3 = session.resource("s3", region_name="us-east-1")
+ return s3
+ raise botocore.exceptions.NoCredentialsError("S3 credentials are undefined.")
+
+
+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.S3_OPEN_ACCESS_BUCKET. 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.
+ """
+ bucket_name = None
+
+ if project.access_policy == AccessPolicy.OPEN and has_S3_open_data_bucket_name():
+ bucket_name = settings.S3_OPEN_ACCESS_BUCKET
+ else:
+ bucket_name = project.slug
+ 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 retrievedprefixes 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_client()
+
+ # Check if s3 is None
+ if s3 is None:
+ return
+
+ # 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 constructsthe target prefix based on
+ the project's slug and version, and then finds the matching
+ prefixwithin 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.
+ """
+ if project.access_policy != AccessPolicy.OPEN:
+ return None
+ else:
+ target_prefix = project.slug + "/"
+ 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 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_client()
+ # Check if s3 is None
+ if s3 is None:
+ return
+ bucket_name = get_bucket_name(project)
+ try:
+ s3.head_bucket(Bucket=bucket_name)
+ return True
+ except botocore.exceptions.ClientError:
+ return False
+
+
+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 put_bucket_logging(s3, bucket_name, target_bucket, target_prefix):
+ """
+ Configures server access logging for an Amazon S3 bucket.
+
+ Args:
+ s3 (boto3.client): The Amazon S3 client.
+ bucket_name (str): The name of the source bucket.
+ target_bucket (str): The name of the bucket where log
+ files will be stored.
+ target_prefix (str): The prefix for log file names within the
+ target bucket.
+
+ Returns:
+ None
+
+ Note:
+ This method utilizes the `put_bucket_logging` operation to enable
+ server access logging for the specified source bucket, directing
+ logs to the specified target bucket and prefix.
+
+ Example:
+ put_bucket_logging(s3_client, 'source_bucket', 'log_bucket', 'logs/')
+ """
+ logging_config = {
+ 'LoggingEnabled': {
+ 'TargetBucket': target_bucket,
+ 'TargetPrefix': target_prefix,
+ },
+ }
+
+ # Enable bucket logging
+ s3.put_bucket_logging(
+ Bucket=bucket_name,
+ BucketLoggingStatus=logging_config
+ )
+
+
+def send_files_to_s3(folder_path, s3_prefix, bucket_name, project):
+ """
+ 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.
+
+ Raises:
+ ValueError: If AWS_PROFILE is undefined.
+
+ Note:
+ - Ensure that AWS credentials (Access Key and Secret Key) are
+ properly configured for the S3 client used in this function.
+ """
+ if not has_s3_credentials():
+ raise ValueError("AWS_PROFILE is undefined. Please set it in your settings.")
+
+ s3 = create_s3_client()
+ 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)
+ )
+ s3.upload_file(
+ Filename=local_file_path,
+ Bucket=bucket_name,
+ Key=s3_key,
+ )
+
+ # If project has a ZIP file, upload it as well
+ if project.compressed_storage_size:
+ zip_name = project.zip_name(legacy=False)
+ zip_file_path = project.zip_name(full=True)
+ if project.access_policy == AccessPolicy.OPEN:
+ s3_key = os.path.join(f"{project.slug}/", zip_name)
+ else:
+ s3_key = zip_name
+
+ s3.upload_file(
+ Filename=zip_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.
+ """
+ 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 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.
+ aws_ids (list): A list of AWS account IDs and user names
+ allowed to access the bucket.
+ public (bool): True if the bucket should be made public,
+ False otherwise.
+
+ 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.
+ - If 'public' is True, the policy allows public access to the
+ bucket. If 'public' is False, it restricts access to the specified
+ AWS accounts and users.
+ """
+ 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:GetObject", "s3:ListBucket"],
+ "Resource": [
+ f"arn:aws:s3:::{bucket_name}",
+ f"arn:aws:s3:::{bucket_name}/*",
+ ]
+ }
+ ]
+ }
+
+ # 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_client()
+ # Check if s3 is None
+ if s3 is None:
+ return
+ 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 PublicAccessBlock configuration
+ to be applied to the S3 bucket.
+
+ Returns:
+ None
+
+ Note:
+ - 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.
+ """
+ bucket_policy = ""
+ project_name = project.slug + "-" + project.version
+ aws_ids = get_aws_accounts_for_dataset(project_name)
+ if project.access_policy == AccessPolicy.OPEN:
+ s3 = create_s3_client()
+ if s3 is None:
+ return
+ put_public_access_block(s3, 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 = create_bucket_policy(bucket_name, aws_ids, False)
+
+ if bucket_policy not in [None, ""]:
+ 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. Uploads project files from the local directory to the S3 bucket.
+ 3. Updates the bucket's access policy to align with the
+ project's requirements.
+
+ 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.
+ """
+ bucket_name = get_bucket_name(project)
+ # create bucket if it does not exist
+ s3 = create_s3_client()
+ if s3 is None or bucket_name is None:
+ return
+
+ try:
+ create_s3_bucket(s3, bucket_name)
+ except s3.exceptions.BucketAlreadyExists:
+ raise Exception(f"A bucket named {bucket_name} already exists.")
+ except s3.exceptions.BucketAlreadyOwnedByYou:
+ pass
+ put_bucket_logging(
+ s3, bucket_name, settings.S3_SERVER_ACCESS_LOG_BUCKET, bucket_name + "/logs/"
+ )
+ # upload files to bucket
+ folder_path = project.file_root()
+ # set the prefix only for the projects
+ # in the open data bucket
+ if project.access_policy == AccessPolicy.OPEN:
+ s3_prefix = f"{project.slug}/{project.version}/"
+ else:
+ s3_prefix = f"{project.version}/"
+ send_files_to_s3(folder_path, s3_prefix, bucket_name, project)
+ # update bucket's policy for projects
+ update_bucket_policy(project, 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.
+ """
+ published_projects = PublishedProject.objects.all()
+ for project in published_projects:
+ upload_project_to_S3(project)
+
+
+def create_s3_server_access_log_bucket():
+ """
+ Create the bucket for server access logs.
+
+ This creates the bucket designated by S3_SERVER_ACCESS_LOG_BUCKET,
+ and allows it to be used to deposit S3 server access logs. Only
+ buckets owned by the AWS_ACCOUNT_ID account will be allowed to
+ store logs in this bucket.
+
+ If the bucket already exists, an exception will be raised.
+ """
+ s3 = create_s3_client()
+
+ bucket_name = settings.S3_SERVER_ACCESS_LOG_BUCKET
+ source_accounts = [settings.AWS_ACCOUNT_ID]
+
+ s3.create_bucket(Bucket=bucket_name)
+
+ put_public_access_block(s3, bucket_name, True)
+
+ # Policy for logging - see:
+ # https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-server-access-logging.html
+ s3.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps({
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "logging.s3.amazonaws.com",
+ },
+ "Action": [
+ "s3:PutObject",
+ ],
+ "Resource": f"arn:aws:s3:::{bucket_name}/*",
+ "Condition": {
+ "ArnLike": {
+ "aws:SourceArn": "arn:aws:s3:::*",
+ },
+ "StringEquals": {
+ "aws:SourceAccount": source_accounts,
+ },
+ },
+ },
+ ],
+ }))
diff --git a/physionet-django/project/fixtures/demo-project.json b/physionet-django/project/fixtures/demo-project.json
index 856c58ba33..af91d15801 100644
--- a/physionet-django/project/fixtures/demo-project.json
+++ b/physionet-django/project/fixtures/demo-project.json
@@ -339,6 +339,26 @@
"creation_date": "2020-04-10T16:08:49.337Z"
}
},
+{
+ "model": "project.author",
+ "pk": 14,
+ "fields": {
+ "user": [
+ "admin"
+ ],
+ "display_order": 1,
+ "is_submitting": true,
+ "is_corresponding": true,
+ "approval_datetime": null,
+ "content_type": [
+ "project",
+ "activeproject"
+ ],
+ "object_id": 9,
+ "corresponding_email": 209,
+ "creation_date": "2020-04-10T16:08:49.337Z"
+ }
+},
{
"model": "project.publishedauthor",
"pk": 1,
@@ -914,51 +934,6 @@
"description": "An implementation of a statistical or machine learning model with potential for reuse by the research community. Typically models will be created by a training process and may have dependencies on specific computational frameworks."
}
},
-{
- "model": "project.archivedproject",
- "pk": 6,
- "fields": {
- "resource_type": 1,
- "title": "Failed demo software for parsing clinical notes",
- "abstract": "Demo abstract
",
- "background": "Demo background
",
- "methods": "Demo methods
",
- "content_description": "Demo content description
",
- "usage_notes": "Demo usage notes
",
- "installation": "Demo installation
",
- "acknowledgements": "Demo acknowledgements
",
- "conflicts_of_interest": "Demo conflicts of interest
",
- "version": "1.0",
- "release_notes": "Demo release notes
",
- "short_description": "",
- "access_policy": 0,
- "license": null,
- "dua": null,
- "project_home_page": "",
- "core_project": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
- "editor": null,
- "submission_datetime": "2020-04-07T15:54:54.860Z",
- "author_comments": "",
- "editor_assignment_datetime": "2020-04-13T16:06:22.892Z",
- "revision_request_datetime": null,
- "resubmission_datetime": null,
- "editor_accept_datetime": "2020-04-20T16:06:55.526Z",
- "copyedit_completion_datetime": "2020-04-21T16:07:36.681Z",
- "author_approval_datetime": "2020-04-22T16:08:02Z",
- "creation_datetime": "2020-04-10T15:54:54.860Z",
- "modified_datetime": "2020-05-11T16:07:36.681Z",
- "is_new_version": false,
- "slug": "t2ASGLbIBoWaTJvPrM2A",
- "latest_reminder": null,
- "doi": null,
- "archive_datetime": "2020-05-21T16:07:36.681Z",
- "archive_reason": 3,
- "parent_projects": [],
- "programming_languages": [],
- "allow_file_downloads": true,
- "ethics_statement": "The authors declare no ethics concerns."
- }
-},
{
"model": "project.activeproject",
"pk": 1,
@@ -1233,6 +1208,51 @@
"required_trainings": [1]
}
},
+{
+ "model": "project.activeproject",
+ "pk": 9,
+ "fields": {
+ "resource_type": 1,
+ "title": "Failed demo software for parsing clinical notes",
+ "abstract": "Demo abstract
",
+ "background": "Demo background
",
+ "methods": "Demo methods
",
+ "content_description": "Demo content description
",
+ "usage_notes": "Demo usage notes
",
+ "installation": "Demo installation
",
+ "acknowledgements": "Demo acknowledgements
",
+ "conflicts_of_interest": "Demo conflicts of interest
",
+ "version": "2.0",
+ "release_notes": "This is version 2.0
",
+ "short_description": "Demo short description",
+ "access_policy": 0,
+ "license": 9,
+ "dua": null,
+ "project_home_page": "",
+ "core_project": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
+ "editor": null,
+ "submission_datetime": "2020-04-10T16:08:53.263Z",
+ "author_comments": "",
+ "editor_assignment_datetime": "2020-04-10T16:09:06.581Z",
+ "revision_request_datetime": null,
+ "resubmission_datetime": null,
+ "editor_accept_datetime": null,
+ "copyedit_completion_datetime": null,
+ "author_approval_datetime": null,
+ "creation_datetime": "2020-04-10T16:08:49.323Z",
+ "modified_datetime": "2020-04-10T16:08:49.323Z",
+ "is_new_version": false,
+ "slug": "t2ASGLbIBoWaTJvPrM2A",
+ "latest_reminder": null,
+ "doi": null,
+ "submission_status": 5,
+ "parent_projects": [],
+ "programming_languages": [],
+ "allow_file_downloads": true,
+ "ethics_statement": "The authors declare no ethics concerns.",
+ "required_trainings": [1]
+ }
+},
{
"model": "project.publishedproject",
"pk": 1,
@@ -1631,7 +1651,7 @@
"model": "project.programminglanguage",
"pk": 9,
"fields": {
- "name": "Javascript"
+ "name": "JavaScript"
}
},
{
@@ -1704,6 +1724,20 @@
"name": "Swift"
}
},
+{
+ "model": "project.programminglanguage",
+ "pk": 20,
+ "fields": {
+ "name": "AWK"
+ }
+},
+{
+ "model": "project.programminglanguage",
+ "pk": 21,
+ "fields": {
+ "name": "Fortran"
+ }
+},
{
"model": "project.dua",
"pk": 1,
diff --git a/physionet-django/project/fixtures/site-data.json b/physionet-django/project/fixtures/site-data.json
index 5e5b8fbb5e..79b0e1b967 100644
--- a/physionet-django/project/fixtures/site-data.json
+++ b/physionet-django/project/fixtures/site-data.json
@@ -200,7 +200,7 @@
"model": "project.programminglanguage",
"pk": 9,
"fields": {
- "name": "Javascript"
+ "name": "JavaScript"
}
},
{
@@ -273,6 +273,20 @@
"name": "Swift"
}
},
+{
+ "model": "project.programminglanguage",
+ "pk": 20,
+ "fields": {
+ "name": "AWK"
+ }
+},
+{
+ "model": "project.programminglanguage",
+ "pk": 21,
+ "fields": {
+ "name": "Fortran"
+ }
+},
{
"model": "project.documenttype",
"pk": 1,
diff --git a/physionet-django/project/forms.py b/physionet-django/project/forms.py
index 7ee1985572..89804bd803 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
@@ -851,6 +876,12 @@ def __init__(self, *args, **kwargs):
if not settings.ENABLE_FILE_DOWNLOADS_OPTION:
del self.fields['allow_file_downloads']
+ if settings.ALLOWED_ACCESS_POLICIES:
+ self.fields['access_policy'].choices = [
+ (value, label) for (value, label) in AccessPolicy.choices()
+ if AccessPolicy(value).name in settings.ALLOWED_ACCESS_POLICIES
+ ]
+
if self.access_policy is None:
self.access_policy = self.instance.access_policy
diff --git a/physionet-django/project/migrations/0066_migrate_references_to_use_order.py b/physionet-django/project/migrations/0066_migrate_references_to_use_order.py
index aa0176353c..e273aeb381 100644
--- a/physionet-django/project/migrations/0066_migrate_references_to_use_order.py
+++ b/physionet-django/project/migrations/0066_migrate_references_to_use_order.py
@@ -1,8 +1,10 @@
from django.db import migrations, models
-from project.models import PublishedProject, ActiveProject, ArchivedProject, LegacyProject
def migrate_forward(apps, schema_editor):
+ PublishedProject = apps.get_model("project", "PublishedProject")
+ ActiveProject = apps.get_model("project", "ActiveProject")
+ ArchivedProject = apps.get_model("project", "ArchivedProject")
for project in PublishedProject.objects.all():
refs = project.references.all().order_by('id')
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"),
+ ],
+ },
+ ),
+ ]
diff --git a/physionet-django/project/migrations/0072_aws.py b/physionet-django/project/migrations/0072_aws.py
new file mode 100644
index 0000000000..efbc16ad69
--- /dev/null
+++ b/physionet-django/project/migrations/0072_aws.py
@@ -0,0 +1,44 @@
+# Generated by Django 4.1.10 on 2023-11-21 21:02
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("project", "0071_alter_activeproject_options_and_more"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AWS",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("bucket_name", models.CharField(max_length=150, null=True)),
+ ("is_private", models.BooleanField(default=False)),
+ ("sent_zip", models.BooleanField(default=False)),
+ ("sent_files", models.BooleanField(default=False)),
+ ("creation_datetime", models.DateTimeField(auto_now_add=True)),
+ ("finished_datetime", models.DateTimeField(null=True)),
+ (
+ "project",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="aws",
+ to="project.publishedproject",
+ ),
+ ),
+ ],
+ options={
+ "default_permissions": (),
+ },
+ ),
+ ]
diff --git a/physionet-django/project/migrations/0073_activeproject_archive_datetime.py b/physionet-django/project/migrations/0073_activeproject_archive_datetime.py
new file mode 100644
index 0000000000..4260825436
--- /dev/null
+++ b/physionet-django/project/migrations/0073_activeproject_archive_datetime.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.1.10 on 2023-12-01 07:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("project", "0072_aws"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="activeproject",
+ name="archive_datetime",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/physionet-django/project/migrations/0074_migrate_archived_to_active.py b/physionet-django/project/migrations/0074_migrate_archived_to_active.py
new file mode 100644
index 0000000000..b1bd4707f1
--- /dev/null
+++ b/physionet-django/project/migrations/0074_migrate_archived_to_active.py
@@ -0,0 +1,69 @@
+from django.db import migrations
+from django.contrib.contenttypes.models import ContentType
+from project.models import SubmissionStatus
+
+
+def migrate_archived_to_active(apps, schema_editor):
+ """
+ Copy all ArchivedProject data to the ActiveProject model.
+ """
+ AnonymousAccess = apps.get_model("project", "AnonymousAccess")
+ ActiveProject = apps.get_model("project", "ActiveProject")
+ ArchivedProject = apps.get_model("project", "ArchivedProject")
+ Author = apps.get_model("project", "Author")
+ CopyeditLog = apps.get_model("project", "CopyeditLog")
+ EditLog = apps.get_model("project", "EditLog")
+ Log = apps.get_model("project", "Log")
+ Publication = apps.get_model("project", "Publication")
+ Reference = apps.get_model("project", "Reference")
+ Topic = apps.get_model("project", "Topic")
+ UploadedDocument = apps.get_model("project", "UploadedDocument")
+
+ # Get content types for both models
+ archived_project_type = ContentType.objects.get_for_model(ArchivedProject)
+ active_project_type = ContentType.objects.get_for_model(ActiveProject)
+
+ for archived_project in ArchivedProject.objects.all():
+ # Create a new ActiveProject instance
+ active_project = ActiveProject(
+ submission_status=SubmissionStatus.ARCHIVED.value,
+ )
+ for attr in [f.name for f in ArchivedProject._meta.fields]:
+ if attr != 'id':
+ setattr(active_project, attr, getattr(archived_project, attr))
+ active_project.save()
+
+ # Migrate references, authors, logs, etc from ArchivedProject to ActiveProject
+ for model in [Reference,
+ Author,
+ Log,
+ AnonymousAccess,
+ Topic,
+ Publication,
+ UploadedDocument,
+ EditLog,
+ CopyeditLog]:
+ items = model.objects.filter(
+ content_type_id=archived_project_type.id, object_id=archived_project.id
+ )
+ for item in items:
+ item.content_type_id = active_project_type.id
+ item.object_id = active_project.id
+ item.save()
+
+ # Delete the archived project
+ archived_project.delete()
+
+
+def migrate_backward(apps, schema_editor):
+ pass
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("project", "0073_activeproject_archive_datetime"),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_archived_to_active, migrate_backward),
+ ]
diff --git a/physionet-django/project/modelcomponents/activeproject.py b/physionet-django/project/modelcomponents/activeproject.py
index 3bdd73e426..c9f8b82c87 100644
--- a/physionet-django/project/modelcomponents/activeproject.py
+++ b/physionet-django/project/modelcomponents/activeproject.py
@@ -19,7 +19,6 @@
from console.tasks import associated_task
from physionet.settings.base import StorageTypes
from project.modelcomponents.access import AccessPolicy
-from project.modelcomponents.archivedproject import ArchivedProject
from project.modelcomponents.authors import PublishedAffiliation, PublishedAuthor
from project.modelcomponents.metadata import (
Contact,
@@ -88,6 +87,11 @@ class SubmissionStatus(IntEnum):
ready, the submitting author may submit the project, which moves
it to NEEDS_ASSIGNMENT.
+ 5: ARCHIVED
+ --------------
+ The project has been archived. In this stage, the project cannot be
+ edited. To recover the project, it must be returned to UNSUBMITTED status.
+
10: NEEDS_ASSIGNMENT ("Awaiting Editor Assignment")
---------------------------------------------------
The project has been submitted, but has no editor assigned. A
@@ -99,8 +103,8 @@ class SubmissionStatus(IntEnum):
An editor has been assigned and needs to review the project. The
editor may accept the project, which moves it to NEEDS_COPYEDIT;
may request resubmission, which moves the project to
- NEEDS_RESUBMISSION; or may reject the project, which deletes the
- ActiveProject and transfers its content to an ArchivedProject.
+ NEEDS_RESUBMISSION; or may reject the project, which sets the
+ status of the ActiveProject to "Archived".
30: NEEDS_RESUBMISSION ("Awaiting Author Revisions")
-------------------------------------------------
@@ -130,6 +134,7 @@ class SubmissionStatus(IntEnum):
to a PublishedProject.
"""
UNSUBMITTED = 0
+ ARCHIVED = 5
NEEDS_ASSIGNMENT = 10
NEEDS_DECISION = 20
NEEDS_RESUBMISSION = 30
@@ -150,7 +155,6 @@ class ActiveProject(Metadata, UnpublishedProject, SubmissionInfo):
submission_status = models.PositiveSmallIntegerField(default=0)
# Max number of active submitting projects a user is allowed to have
- MAX_SUBMITTING_PROJECTS = 10
INDIVIDUAL_FILE_SIZE_LIMIT = 10 * 1024**3
# Subdirectory (under self.files.file_root) where files are stored
@@ -196,6 +200,7 @@ class ActiveProject(Metadata, UnpublishedProject, SubmissionInfo):
SUBMISSION_STATUS_LABELS = {
SubmissionStatus.UNSUBMITTED: 'Not submitted.',
+ SubmissionStatus.ARCHIVED: 'Archived.',
SubmissionStatus.NEEDS_ASSIGNMENT: 'Awaiting editor assignment.',
SubmissionStatus.NEEDS_DECISION: 'Awaiting editor decision.',
SubmissionStatus.NEEDS_RESUBMISSION: 'Revisions requested.',
@@ -210,6 +215,7 @@ class Meta:
('can_assign_editor', 'Can assign editor'),
('can_edit_activeprojects', 'Can edit ActiveProjects')
]
+ ordering = ('title', 'creation_datetime')
def storage_used(self):
"""
@@ -289,73 +295,16 @@ def copyeditable(self):
if self.submission_status == SubmissionStatus.NEEDS_COPYEDIT:
return True
- def archive(self, archive_reason):
+ def archive(self, archive_reason, clear_files=False):
"""
- Archive the project. Create an ArchivedProject object, copy over
- the fields, and delete this object
+ Archive the project. Sets the status of the project to "Archived" object.
"""
- archived_project = ArchivedProject(archive_reason=archive_reason,
- slug=self.slug)
-
- modified_datetime = self.modified_datetime
+ self.submission_status = SubmissionStatus.ARCHIVED
+ self.archive_datetime = timezone.now()
+ self.save()
- # Direct copy over fields
- for attr in [f.name for f in Metadata._meta.fields] + [f.name for f in SubmissionInfo._meta.fields]:
- setattr(archived_project, attr, getattr(self, attr))
-
- archived_project.save()
-
- # Redirect the related objects
- for reference in self.references.all():
- reference.project = archived_project
- reference.save()
- for publication in self.publications.all():
- publication.project = archived_project
- publication.save()
- for topic in self.topics.all():
- topic.project = archived_project
- topic.save()
- for author in self.authors.all():
- author.project = archived_project
- author.save()
- for edit_log in self.edit_logs.all():
- edit_log.project = archived_project
- edit_log.save()
- for copyedit_log in self.copyedit_logs.all():
- copyedit_log.project = archived_project
- copyedit_log.save()
- for parent_project in self.parent_projects.all():
- archived_project.parent_projects.add(parent_project)
-
- UploadedDocument.objects.filter(
- object_id=self.pk, content_type=ContentType.objects.get_for_model(ActiveProject)
- ).update(object_id=archived_project.pk, content_type=ContentType.objects.get_for_model(ArchivedProject))
-
- if self.resource_type.id == 1:
- languages = self.programming_languages.all()
- if languages:
- archived_project.programming_languages.add(*list(languages))
-
- # Voluntary delete
- if archive_reason == 1:
+ if clear_files:
self.clear_files()
- else:
- # Move over files
- self.files.rename(self.file_root(), archived_project.file_root())
-
- # Copy the ActiveProject timestamp to the ArchivedProject.
- # Since this is an auto_now field, save() doesn't allow
- # setting an arbitrary value.
- queryset = ArchivedProject.objects.filter(id=archived_project.id)
- queryset.update(modified_datetime=modified_datetime)
-
- return self.delete()
-
- def fake_delete(self):
- """
- Appear to delete this project. Actually archive it.
- """
- self.archive(archive_reason=1)
def check_integrity(self):
"""
@@ -468,7 +417,7 @@ def reject(self):
"""
Reject a project under submission
"""
- self.archive(archive_reason=3)
+ self.archive(archive_reason=0)
def is_resubmittable(self):
"""
diff --git a/physionet-django/project/modelcomponents/archivedproject.py b/physionet-django/project/modelcomponents/archivedproject.py
index 7c65d50204..5037c3a0b5 100644
--- a/physionet-django/project/modelcomponents/archivedproject.py
+++ b/physionet-django/project/modelcomponents/archivedproject.py
@@ -10,6 +10,9 @@
class ArchivedProject(Metadata, UnpublishedProject, SubmissionInfo):
"""
+ THIS MODEL WILL BE DEPRECATED. INSTEAD, USE ACTIVEPROJECT
+ WITH SUBMISSIONSTATUS=ARCHIVED.
+
An archived project. Created when (maps to archive_reason):
1. A user chooses to 'delete' their ActiveProject.
2. An ActiveProject is not submitted for too long.
diff --git a/physionet-django/project/modelcomponents/authors.py b/physionet-django/project/modelcomponents/authors.py
index 1e96bcf3ba..3eab9d134d 100644
--- a/physionet-django/project/modelcomponents/authors.py
+++ b/physionet-django/project/modelcomponents/authors.py
@@ -102,7 +102,7 @@ def __str__(self):
class Author(BaseAuthor):
"""
- The author model for ArchivedProject/ActiveProject
+ The author model for ActiveProject
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
diff --git a/physionet-django/project/modelcomponents/coreproject.py b/physionet-django/project/modelcomponents/coreproject.py
index 2cddf9380a..70a7937fb6 100644
--- a/physionet-django/project/modelcomponents/coreproject.py
+++ b/physionet-django/project/modelcomponents/coreproject.py
@@ -4,7 +4,6 @@
from django.db import models
from project.modelcomponents.activeproject import ActiveProject
-from project.modelcomponents.archivedproject import ArchivedProject
from project.modelcomponents.publishedproject import PublishedProject
@@ -87,6 +86,4 @@ def exists_project_slug(slug):
Whether the slug has been taken by an existing project of any
kind.
"""
- return bool(ActiveProject.objects.filter(slug=slug)
- or ArchivedProject.objects.filter(slug=slug)
- or PublishedProject.objects.filter(slug=slug))
+ return bool(ActiveProject.objects.filter(slug=slug) or PublishedProject.objects.filter(slug=slug))
diff --git a/physionet-django/project/modelcomponents/metadata.py b/physionet-django/project/modelcomponents/metadata.py
index 37eccef5c0..f0abb1e1ad 100644
--- a/physionet-django/project/modelcomponents/metadata.py
+++ b/physionet-django/project/modelcomponents/metadata.py
@@ -25,9 +25,9 @@ class Metadata(models.Model):
"""
Visible content of a published or unpublished project.
- Every project (ActiveProject, PublishedProject, and
- ArchivedProject) inherits from this class as well as
- SubmissionInfo. The difference is that the fields of this class
+ Every project (ActiveProject, PublishedProject) inherits
+ from this class as well as SubmissionInfo.
+ The difference is that the fields of this class
contain public information that will be shown on the published
project pages; SubmissionInfo contains internal information about
the publication process.
@@ -534,7 +534,7 @@ def citation_text_all(self):
class Topic(models.Model):
"""
- Topic information to tag ActiveProject/ArchivedProject
+ Topic information to tag ActiveProject
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
@@ -576,7 +576,7 @@ def __str__(self):
class Reference(models.Model):
"""
- Reference field for ActiveProject/ArchivedProject
+ Reference field for ActiveProject
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
@@ -643,7 +643,7 @@ class Meta:
class Publication(BasePublication):
"""
- Publication for ArchivedProject/ActiveProject
+ Publication for ActiveProject
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
diff --git a/physionet-django/project/modelcomponents/publishedproject.py b/physionet-django/project/modelcomponents/publishedproject.py
index 2b6c91ce34..9b5d25a89d 100644
--- a/physionet-django/project/modelcomponents/publishedproject.py
+++ b/physionet-django/project/modelcomponents/publishedproject.py
@@ -8,6 +8,8 @@
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
+
+from notification.models import News
from project.managers.publishedproject import PublishedProjectManager
from project.modelcomponents.access import DataAccessRequest, DataAccessRequestReviewer, DUASignature
from project.modelcomponents.fields import SafeHTMLField
@@ -77,6 +79,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))
@@ -123,11 +126,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
@@ -366,3 +379,19 @@ def embargo_active(self):
return True
else:
return False
+
+ def get_all_news(self):
+ """
+ Return all news items associated with this project. If any news item has
+ 'link_all_versions' set to True, include those news too.
+ """
+ # Fetch news items directly related to this PublishedProject
+ direct_news = self.news.all()
+
+ # Fetch news items related to other PublishedProjects where the
+ # link_all_versions flag is set to True
+ linked_news = News.objects.filter(
+ project__core_project=self.core_project,
+ link_all_versions=True).exclude(project=self)
+
+ return direct_news | linked_news
diff --git a/physionet-django/project/modelcomponents/storage.py b/physionet-django/project/modelcomponents/storage.py
index 3b0bcfd465..e5c444ca83 100644
--- a/physionet-django/project/modelcomponents/storage.py
+++ b/physionet-django/project/modelcomponents/storage.py
@@ -41,3 +41,33 @@ class GCP(models.Model):
class Meta:
default_permissions = ()
+
+ def __str__(self):
+ return self.bucket_name
+
+
+class AWS(models.Model):
+ """
+ Store all of the AWS information with a relation to a project.
+ """
+ project = models.OneToOneField(
+ "project.PublishedProject", related_name="aws", on_delete=models.CASCADE
+ )
+ bucket_name = models.CharField(max_length=150, null=True)
+ is_private = models.BooleanField(default=False)
+ sent_zip = models.BooleanField(default=False)
+ sent_files = models.BooleanField(default=False)
+ creation_datetime = models.DateTimeField(auto_now_add=True)
+ finished_datetime = models.DateTimeField(null=True)
+
+ class Meta:
+ default_permissions = ()
+
+ def s3_uri(self):
+ if self.is_private:
+ return f's3://{self.bucket_name}/{self.project.version}/'
+ else:
+ return f's3://{self.bucket_name}/{self.project.slug}/{self.project.version}/'
+
+ def __str__(self):
+ return self.s3_uri()
diff --git a/physionet-django/project/modelcomponents/submission.py b/physionet-django/project/modelcomponents/submission.py
index 1117f9b6d3..fce3113378 100644
--- a/physionet-django/project/modelcomponents/submission.py
+++ b/physionet-django/project/modelcomponents/submission.py
@@ -188,8 +188,8 @@ class SubmissionInfo(models.Model):
"""
Submission information, inherited by all projects.
- Every project (ActiveProject, PublishedProject, and
- ArchivedProject) inherits from this class as well as Metadata.
+ Every project (ActiveProject, PublishedProject) inherits
+ from this class as well as Metadata.
The difference is that the fields of this class contain internal
information about the publication process; Metadata contains the
public information that will be shown on the published project
diff --git a/physionet-django/project/modelcomponents/unpublishedproject.py b/physionet-django/project/modelcomponents/unpublishedproject.py
index b05566e76a..86233a5f54 100644
--- a/physionet-django/project/modelcomponents/unpublishedproject.py
+++ b/physionet-django/project/modelcomponents/unpublishedproject.py
@@ -15,7 +15,7 @@
class UnpublishedProject(models.Model):
"""
- Abstract model inherited by ArchivedProject/ActiveProject
+ Abstract model inherited by ActiveProject
"""
# Date and time that the project's content was modified.
@@ -32,7 +32,7 @@ class UnpublishedProject(models.Model):
references = GenericRelation('project.Reference')
publications = GenericRelation('project.Publication')
topics = GenericRelation('project.Topic')
-
+ archive_datetime = models.DateTimeField(null=True, blank=True)
class Meta:
abstract = True
@@ -103,6 +103,29 @@ def has_wfdb(self):
"""
return self.files.has_wfdb_files(self)
+ @property
+ def editor_contact_email(self):
+ """
+ Email address for contacting the project editor.
+
+ If a site-wide contact address is configured, that address
+ will be used for all projects. The string PROJECT-SLUG can be
+ included and will be replaced by the active project slug (for
+ example, PROJECT_EDITOR_EMAIL can be set to
+ 'editor+PROJECT-SLUG@example.org', if the mail server
+ understands how to handle it.)
+
+ If there is no site-wide contact address, the primary email
+ address of the assigned editor is used.
+ """
+ if not self.editor:
+ return None
+ elif settings.PROJECT_EDITOR_EMAIL:
+ return settings.PROJECT_EDITOR_EMAIL.replace('PROJECT-SLUG',
+ self.slug)
+ else:
+ return self.editor.email
+
def content_modified(self):
"""
Update the project's modification timestamp.
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/active_submission_timeline.html b/physionet-django/project/templates/project/active_submission_timeline.html
index 9c6a09943d..69d084c136 100644
--- a/physionet-django/project/templates/project/active_submission_timeline.html
+++ b/physionet-django/project/templates/project/active_submission_timeline.html
@@ -130,6 +130,17 @@ Resubmit Project
{% endfor %}
{% endif %}
+ {% if project.archive_datetime %}
+
+
+
{{ project.archive_datetime|date }}
+
+ The project was archived.
+
+
+
+ {% endif %}
+
{# At this point, there may have been any number of submissions #}
{% for e in edit_logs reversed %}
@@ -139,7 +150,7 @@ Resubmit Project
{{ e.decision_datetime|date }}
{% if e.decision == 0 %}
-
: The editor rejected the submission.
+
The editor rejected the submission.
{% elif e.decision == 1 %}
The editor requested a resubmission with revisions.
{% elif e.decision == 2 %}
@@ -192,7 +203,7 @@
Resubmit Project
{{ project.editor_assignment_datetime|date }}
-
The project was assigned to an editor: {{ project.editor.disp_name_email }}
+
The project was assigned to an editor. To contact the editor, email: {{ contact_email }}
{% endif %}
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 %}
+
+
+
+
+
+
+
+
Please confirm that you would like to assign ' ' as the new submitting author.
+
+
+
+
+
+
+
{% endblock %}
{% block local_js_bottom %}
@@ -177,8 +217,14 @@
Your Affiliations
+
{# Disable submission if not currently editable #}
{% if not project.author_editable %}
{% endif %}
+
+{% if is_submitting %}
+
+{% endif %}
+
{% endblock %}
diff --git a/physionet-django/project/templates/project/published_project.html b/physionet-django/project/templates/project/published_project.html
index 3ec8ddccd7..e729088d0a 100644
--- a/physionet-django/project/templates/project/published_project.html
+++ b/physionet-django/project/templates/project/published_project.html
@@ -430,6 +430,12 @@
Access the files
wget -r -N -c -np{% if project.access_policy %} --user {{ user }} --ask-password{% endif %} {{ bulk_url_prefix }}{% url 'serve_published_project_file' project.slug project.version '' %}
{% endif %}
+ {% if has_s3_credentials and project.aws.sent_files %}
+
+ Download the files using AWS command line tools:
+ aws s3 sync {{ project.aws.s3_uri }} DESTINATION
+
+ {% endif %}
{# ZIP END #}
diff --git a/physionet-django/project/templates/project/static_submission_timeline.html b/physionet-django/project/templates/project/static_submission_timeline.html
index 06e6d069e1..52db7566d8 100644
--- a/physionet-django/project/templates/project/static_submission_timeline.html
+++ b/physionet-django/project/templates/project/static_submission_timeline.html
@@ -51,26 +51,23 @@
{% endfor %}
{% endif %}
-
- {# There may have been any number of submissions #}
- {% for e in edit_logs %}
- {% if e.is_resubmission %}
-
-
-
{{ e.submission_datetime|date }}
-
-
The project was resubmitted for review.
- {% if e.author_comments %}
-
The submitting author included the following comments:
-
- {% endif %}
-
+ {% if project.archive_datetime %}
+
+
+
{{ project.archive_datetime|date }}
+
+ The project was archived.
-
- {% endif %}
+
+
+ {% endif %}
+
+ {# At this point, there may have been any number of submissions #}
+ {% for e in edit_logs reversed %}
+ {% if e.decision_datetime %}
{{ e.decision_datetime|date }}
{% if e.decision == 0 %}
@@ -86,12 +83,31 @@
{{ result }}
{% endfor %}
-
-
The editor comments regarding the submission are as follows:
-
-
+
+
The editor comments regarding the submission are as follows:
+
-
+ {# No decision yet #}
+ {% else %}
+
Currently
+
Waiting for editor decision
+ {% endif %}
+
+
+ {% if e.is_resubmission %}
+
+
+
{{ e.submission_datetime|date }}
+
+
The project was resubmitted for review.
+ {% if e.author_comments %}
+
The submitting author included the following comments:
+
+ {% endif %}
+
+
+
+ {% endif %}
{% endfor %}
diff --git a/physionet-django/project/test_s3.py b/physionet-django/project/test_s3.py
new file mode 100644
index 0000000000..572bf68983
--- /dev/null
+++ b/physionet-django/project/test_s3.py
@@ -0,0 +1,141 @@
+import os
+from unittest import mock
+
+from django.conf import settings
+from django.test import TestCase, override_settings
+from moto import mock_s3
+
+from project.cloud.s3 import (
+ check_s3_bucket_exists,
+ create_s3_client,
+ create_s3_server_access_log_bucket,
+ get_bucket_name,
+ has_s3_credentials,
+ upload_project_to_S3,
+)
+from project.models import PublishedProject
+from user.test_views import TestMixin
+
+
+@override_settings(
+ AWS_PROFILE='default',
+ AWS_ACCOUNT_ID='123456789012',
+ S3_OPEN_ACCESS_BUCKET='datashare-public',
+ S3_SERVER_ACCESS_LOG_BUCKET='datashare-logs',
+)
+class TestS3(TestMixin):
+ """
+ Test cases for S3 project uploads.
+ """
+ maxDiff = None
+
+ def setUp(self):
+ super().setUp()
+
+ # The following environment variables are used by boto3, and
+ # should be set to avoid unpredictable behavior when testing.
+ # They need to be set before calling mock_s3(). This list
+ # might be incomplete.
+ self.mock_env = mock.patch.dict(os.environ, {
+ 'AWS_SHARED_CREDENTIALS_FILE': os.path.join(
+ settings.DEMO_FILE_ROOT, 'aws_credentials'),
+ 'AWS_PROFILE': settings.AWS_PROFILE,
+ 'AWS_ACCESS_KEY_ID': '',
+ 'AWS_SECRET_ACCESS_KEY': '',
+ 'AWS_SECURITY_TOKEN': '',
+ 'AWS_SESSION_TOKEN': '',
+ 'AWS_DEFAULT_REGION': '',
+ })
+ self.mock_env.start()
+ self.mock_s3 = mock_s3()
+ self.mock_s3.start()
+
+ def tearDown(self):
+ super().tearDown()
+ self.mock_s3.stop()
+ self.mock_env.stop()
+
+ def test_s3_credentials(self):
+ """
+ Check that dummy credentials are configured for S3.
+ """
+ self.assertTrue(has_s3_credentials())
+
+ def test_create_log_bucket(self):
+ """
+ Test creating an S3 bucket for server access logs.
+ """
+ create_s3_server_access_log_bucket()
+ self.assert_bucket_is_not_public(settings.S3_SERVER_ACCESS_LOG_BUCKET)
+
+ def test_upload_open_projects(self):
+ """
+ Test uploading open-access projects to S3.
+ """
+ create_s3_server_access_log_bucket()
+
+ project1 = PublishedProject.objects.get(slug='demobsn',
+ version='1.0')
+ self.assertGreater(project1.compressed_storage_size, 0)
+
+ project2 = PublishedProject.objects.get(slug='demowave',
+ version='1.0.0')
+ self.assertEqual(project2.compressed_storage_size, 0)
+
+ self.assertFalse(check_s3_bucket_exists(project1))
+ self.assertFalse(check_s3_bucket_exists(project2))
+
+ upload_project_to_S3(project1)
+ upload_project_to_S3(project2)
+
+ self.assertTrue(check_s3_bucket_exists(project1))
+ self.assertTrue(check_s3_bucket_exists(project2))
+
+ s3 = create_s3_client()
+ expected_files = {}
+ for project in (project1, project2):
+ bucket = get_bucket_name(project)
+ self.assert_bucket_is_public(bucket)
+
+ prefix = project.slug + '/' + project.version + '/'
+ for subdir, _, files in os.walk(project.file_root()):
+ for name in files:
+ path = os.path.join(subdir, name)
+ relpath = os.path.relpath(path, project.file_root())
+ expected_files[prefix + relpath] = os.path.getsize(path)
+
+ if project.compressed_storage_size:
+ zip_path = project.zip_name(full=True)
+ zip_key = project.slug + '/' + project.zip_name(legacy=False)
+ expected_files[zip_key] = os.path.getsize(zip_path)
+
+ objects = s3.list_objects_v2(Bucket=bucket)
+ bucket_files = {}
+ for object_info in objects['Contents']:
+ bucket_files[object_info['Key']] = object_info['Size']
+
+ self.assertEqual(bucket_files, expected_files)
+
+ def assert_bucket_is_public(self, bucket_name):
+ """
+ Check that a bucket exists and allows some form of public access.
+ """
+ s3 = create_s3_client()
+ pab = s3.get_public_access_block(Bucket=bucket_name)
+ conf = pab['PublicAccessBlockConfiguration']
+ self.assertFalse(conf['BlockPublicAcls'])
+ self.assertFalse(conf['IgnorePublicAcls'])
+ self.assertFalse(conf['BlockPublicPolicy'])
+ self.assertFalse(conf['RestrictPublicBuckets'])
+
+ def assert_bucket_is_not_public(self, bucket_name):
+ """
+ Check that a bucket exists and does not allow public access.
+ """
+ s3 = create_s3_client()
+ pab = s3.get_public_access_block(Bucket=bucket_name)
+ conf = pab['PublicAccessBlockConfiguration']
+ self.assertTrue(conf['BlockPublicAcls'])
+ self.assertTrue(conf['IgnorePublicAcls'])
+ self.assertTrue(conf['BlockPublicPolicy'])
+ self.assertTrue(conf['RestrictPublicBuckets'])
diff --git a/physionet-django/project/test_views.py b/physionet-django/project/test_views.py
index 97393f01ca..64b74d90a3 100644
--- a/physionet-django/project/test_views.py
+++ b/physionet-django/project/test_views.py
@@ -13,7 +13,6 @@
from project.models import (
AccessPolicy,
ActiveProject,
- ArchivedProject,
Author,
AuthorInvitation,
DataAccessRequest,
@@ -22,6 +21,7 @@
PublishedAuthor,
PublishedProject,
StorageRequest,
+ SubmissionStatus
)
from user.models import User
from user.test_views import TestMixin, prevent_request_warnings
@@ -520,7 +520,10 @@ def test_content(self):
22 7 ,
some ambiguous characters & < >,
,
-
invalid attributes
+
invalid attributes ,
+
,
+
,
+
"""
@@ -532,7 +535,10 @@ def test_content(self):
22 7 ,
some ambiguous characters & < >,
<form>invalid tags</form>,
-
invalid attributes
+
invalid attributes ,
+
,
+
,
+
"""
@@ -553,6 +559,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
@@ -859,15 +904,20 @@ def test_archive(self):
"""
self.client.login(username='rgmark@mit.edu', password='Tester11!')
project = ActiveProject.objects.get(title='MIT-BIH Arrhythmia Database')
+ self.assertTrue(ActiveProject.objects.filter(title='MIT-BIH Arrhythmia Database',
+ submission_status=SubmissionStatus.UNSUBMITTED))
author_id = project.authors.all().first().id
abstract = project.abstract
+
# 'Delete' (archive) the project
response = self.client.post(reverse('project_overview',
args=(project.slug,)), data={'delete_project':''})
- # The ActiveProject model should be replaced, and all its
- # related objects should point to the new ArchivedProject
- self.assertFalse(ActiveProject.objects.filter(title='MIT-BIH Arrhythmia Database'))
- project = ArchivedProject.objects.get(title='MIT-BIH Arrhythmia Database')
+
+ # The ActiveProject model should be set to "Archived" status
+ self.assertFalse(ActiveProject.objects.filter(title='MIT-BIH Arrhythmia Database',
+ submission_status=SubmissionStatus.UNSUBMITTED))
+ project = ActiveProject.objects.get(title='MIT-BIH Arrhythmia Database',
+ submission_status=SubmissionStatus.ARCHIVED)
self.assertTrue(Author.objects.get(id=author_id).project == project)
self.assertEqual(project.abstract, abstract)
diff --git a/physionet-django/project/utility.py b/physionet-django/project/utility.py
index 64f623941d..587ffedfdc 100644
--- a/physionet-django/project/utility.py
+++ b/physionet-django/project/utility.py
@@ -551,6 +551,8 @@ class LinkFilter:
>>> f = LinkFilter(my_hostnames=['example.com'])
>>> f.convert('
')
'
'
+ >>> f.convert('
')
+ '
'
>>> f.convert('
')
'
'
@@ -667,7 +669,7 @@ def convert_url(self, attr_name, url):
if scheme in ('http', 'https') and self.my_netloc_re.fullmatch(netloc):
url = urllib.parse.urlunparse(('', '', path, params,
query, fragment))
- elif attr_name == 'src':
+ elif (scheme or netloc) and attr_name == 'src':
# Discard cross-domain subresources
return None
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/project/views.py b/physionet-django/project/views.py
index a680ab43f5..f66e803e7a 100644
--- a/physionet-django/project/views.py
+++ b/physionet-django/project/views.py
@@ -33,7 +33,6 @@
ActiveProject,
Affiliation,
AnonymousAccess,
- ArchivedProject,
Author,
AuthorInvitation,
DataAccess,
@@ -56,7 +55,10 @@
from project.validators import validate_filename, validate_gcs_bucket_object
from user.forms import AssociatedEmailChoiceForm
from user.models import CloudInformation, CredentialApplication, LegacyCredential, User, Training
-
+from project.cloud.s3 import (
+ has_s3_credentials,
+ files_sent_to_S3,
+)
from django.db.models import F, DateTimeField, ExpressionWrapper
LOGGER = logging.getLogger(__name__)
@@ -117,6 +119,13 @@ def view_wrapper(request, *args, **kwargs):
else:
allow = False
+ # Authors cannot view archived projects
+ if (
+ project.submission_status == SubmissionStatus.ARCHIVED
+ and not user.has_perm("project.change_activeproject")
+ ):
+ allow = False
+
# Post authentication
if request.method == 'POST':
if post_auth_mode == 1:
@@ -218,19 +227,15 @@ def project_home(request):
InvitationResponseFormSet = modelformset_factory(AuthorInvitation,
form=forms.InvitationResponseForm, extra=0)
- active_authors = Author.objects.filter(user=user,
- content_type=ContentType.objects.get_for_model(ActiveProject))
- archived_authors = Author.objects.filter(user=user,
- content_type=ContentType.objects.get_for_model(ArchivedProject))
- published_authors = PublishedAuthor.objects.filter(user=user,
- project__is_latest_version=True)
request_reviewers = DataAccessRequestReviewer.objects.filter(reviewer=user,
is_revoked=False,
project__is_latest_version=True)
+ published_projects = PublishedProject.objects.filter(authors__user=user,
+ is_latest_version=True).order_by("-publish_datetime")
+
# Get the various projects.
- projects = [a.project for a in active_authors]
- published_projects = [a.project for a in published_authors] + [ a.project for a in request_reviewers]
+ published_projects = [a for a in published_projects] + [a.project for a in request_reviewers]
for p in published_projects:
p.new_button = p.can_publish_new(user)
p.requests_button = p.can_approve_requests(user)
@@ -248,7 +253,11 @@ def project_home(request):
pending_author_approvals = []
missing_affiliations = []
pending_revisions = []
- for p in projects:
+
+ active_projects = ActiveProject.objects.filter(authors__user=user).exclude(
+ submission_status=SubmissionStatus.ARCHIVED).order_by("-creation_datetime")
+
+ for p in active_projects:
if (p.submission_status == SubmissionStatus.NEEDS_APPROVAL
and not p.all_authors_approved()):
if p.authors.get(user=user).is_submitting:
@@ -260,7 +269,9 @@ def project_home(request):
pending_revisions.append(p)
if p.submission_status == SubmissionStatus.UNSUBMITTED and p.authors.get(user=user).affiliations.count() == 0:
missing_affiliations.append([p, p.authors.get(user=user).creation_date])
- archived_projects = [a.project for a in archived_authors]
+
+ archived_projects = ActiveProject.objects.filter(
+ authors__user=user, submission_status=SubmissionStatus.ARCHIVED).order_by("-creation_datetime")
invitation_response_formset = InvitationResponseFormSet(
queryset=AuthorInvitation.get_user_invitations(user))
@@ -276,7 +287,7 @@ def project_home(request):
request,
'project/project_home.html',
{
- 'projects': projects,
+ 'projects': active_projects,
'published_projects': published_projects,
'archived_projects': archived_projects,
'missing_affiliations': missing_affiliations,
@@ -295,11 +306,22 @@ def create_project(request):
user = request.user
- n_submitting = Author.objects.filter(user=user, is_submitting=True,
- content_type=ContentType.objects.get_for_model(ActiveProject)).count()
- if n_submitting >= ActiveProject.MAX_SUBMITTING_PROJECTS:
- return render(request, 'project/project_limit_reached.html',
- {'max_projects':ActiveProject.MAX_SUBMITTING_PROJECTS})
+ # Filter ActiveProject instances (excluding ARCHIVED)
+ # Get Author objects related to these ActiveProjects
+ active_projects = ActiveProject.objects.exclude(submission_status=SubmissionStatus.ARCHIVED)
+ n_submitting = Author.objects.filter(
+ user=user,
+ is_submitting=True,
+ content_type=ContentType.objects.get_for_model(ActiveProject),
+ object_id__in=active_projects.values_list('id', flat=True)
+ ).count()
+
+ if n_submitting >= settings.MAX_SUBMITTABLE_PROJECTS:
+ return render(
+ request,
+ "project/project_limit_reached.html",
+ {"max_projects": settings.MAX_SUBMITTABLE_PROJECTS},
+ )
if request.method == 'POST':
form = forms.CreateProjectForm(user=user, data=request.POST)
@@ -324,11 +346,22 @@ def new_project_version(request, project_slug):
user = request.user
- n_submitting = Author.objects.filter(user=user, is_submitting=True,
- content_type=ContentType.objects.get_for_model(ActiveProject)).count()
- if n_submitting >= ActiveProject.MAX_SUBMITTING_PROJECTS:
- return render(request, 'project/project_limit_reached.html',
- {'max_projects':ActiveProject.MAX_SUBMITTING_PROJECTS})
+ # Filter ActiveProject instances (excluding ARCHIVED)
+ # Get Author objects related to these ActiveProjects
+ active_projects = ActiveProject.objects.exclude(submission_status=SubmissionStatus.ARCHIVED)
+ n_submitting = Author.objects.filter(
+ user=user,
+ is_submitting=True,
+ content_type=ContentType.objects.get_for_model(ActiveProject),
+ object_id__in=active_projects.values_list('id', flat=True)
+ ).count()
+
+ if n_submitting >= settings.MAX_SUBMITTABLE_PROJECTS:
+ return render(
+ request,
+ "project/project_limit_reached.html",
+ {"max_projects": settings.MAX_SUBMITTABLE_PROJECTS},
+ )
previous_projects = PublishedProject.objects.filter(
slug=project_slug).order_by('-version_order')
@@ -367,7 +400,7 @@ def project_overview(request, project_slug, **kwargs):
under_submission = project.under_submission()
if request.method == 'POST' and 'delete_project' in request.POST and is_submitting and not under_submission:
- project.fake_delete()
+ project.archive(archive_reason=1, clear_files=True)
return redirect('delete_project_success')
return render(request, 'project/project_overview.html',
@@ -536,8 +569,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,18 +626,37 @@ 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)
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,
- '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):
@@ -1364,11 +1418,20 @@ def project_submission(request, project_slug, **kwargs):
else:
edit_logs, copyedit_logs = None, None
- return render(request, 'project/project_submission.html', {
- 'project':project, 'authors':authors,
- 'is_submitting':is_submitting, 'author_comments_form':author_comments_form,
- 'edit_logs':edit_logs, 'copyedit_logs':copyedit_logs,
- 'awaiting_user_approval':awaiting_user_approval})
+ return render(
+ request,
+ "project/project_submission.html",
+ {
+ "project": project,
+ "authors": authors,
+ "is_submitting": is_submitting,
+ "author_comments_form": author_comments_form,
+ "edit_logs": edit_logs,
+ "copyedit_logs": copyedit_logs,
+ "awaiting_user_approval": awaiting_user_approval,
+ "contact_email": project.editor_contact_email,
+ },
+ )
@project_auth(auth_mode=0, post_auth_mode=2)
@@ -1485,10 +1548,14 @@ def archived_submission_history(request, project_slug):
user = request.user
try:
# Checks if the user is an author
- project = ArchivedProject.objects.get(slug=project_slug, authors__user=user)
- except ArchivedProject.DoesNotExist:
- if user.has_perm('project.change_archivedproject'):
- project = get_object_or_404(ArchivedProject, slug=project_slug)
+ project = ActiveProject.objects.get(slug=project_slug,
+ submission_status=SubmissionStatus.ARCHIVED,
+ authors__user=user)
+ except ActiveProject.DoesNotExist:
+ if user.has_perm('project.change_activeproject'):
+ project = get_object_or_404(ActiveProject,
+ slug=project_slug,
+ submission_status=SubmissionStatus.ARCHIVED)
else:
raise Http404()
@@ -1796,7 +1863,7 @@ def published_project(request, project_slug, version, subdir=''):
topics = project.topics.all()
languages = project.programming_languages.all()
contact = project.contact
- news = project.news.all().order_by('-publish_datetime')
+ news = project.get_all_news().order_by('-publish_datetime')
parent_projects = project.parent_projects.all()
# derived_projects = project.derived_publishedprojects.all()
data_access = DataAccess.objects.filter(project=project)
@@ -1863,6 +1930,7 @@ 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(),
+ 'has_s3_credentials': has_s3_credentials(),
'show_platform_wide_citation': show_platform_wide_citation,
'main_platform_citation': main_platform_citation,
}
@@ -1919,6 +1987,7 @@ def sign_dua(request, project_slug, version):
Page to sign the dua for a protected project.
Both restricted and credentialed policies.
"""
+ from console.views import update_aws_bucket_policy
user = request.user
project = PublishedProject.objects.filter(slug=project_slug, version=version)
if project:
@@ -1943,6 +2012,8 @@ def sign_dua(request, project_slug, version):
if request.method == 'POST' and 'agree' in request.POST:
DUASignature.objects.create(user=user, project=project)
+ if has_s3_credentials() and files_sent_to_S3(project) is not None:
+ update_aws_bucket_policy(project.id)
return render(request, 'project/sign_dua_complete.html', {
'project':project})
diff --git a/physionet-django/search/views.py b/physionet-django/search/views.py
index de990710b3..ab5f17126a 100644
--- a/physionet-django/search/views.py
+++ b/physionet-django/search/views.py
@@ -4,7 +4,6 @@
from functools import reduce
from django.conf import settings
-from django.contrib.postgres.search import SearchQuery, SearchVector, SearchRank
from django.db.models import Case, Count, IntegerField, Q, Sum, Value, When
from django.http import Http404
from django.shortcuts import redirect, render, reverse
@@ -63,6 +62,11 @@ def get_content(resource_type, orderby, direction, search_term):
def get_content_postgres_full_text_search(resource_type, orderby, direction, search_term):
+ from django.contrib.postgres.search import (
+ SearchQuery,
+ SearchRank,
+ SearchVector,
+ )
# Split search term by whitespace or punctuation
if search_term:
diff --git a/physionet-django/static/images/about/citi-course-instructions-6.png b/physionet-django/static/images/about/citi-course-instructions-6.png
new file mode 100644
index 0000000000..6a1361e646
Binary files /dev/null and b/physionet-django/static/images/about/citi-course-instructions-6.png differ
diff --git a/physionet-django/static/images/about/citi-course-instructions-7.png b/physionet-django/static/images/about/citi-course-instructions-7.png
new file mode 100644
index 0000000000..7c8d221bb4
Binary files /dev/null and b/physionet-django/static/images/about/citi-course-instructions-7.png differ
diff --git a/physionet-django/static/sample/create-course-schema.json b/physionet-django/static/sample/create-course-schema.json
new file mode 100644
index 0000000000..30f9b822fe
--- /dev/null
+++ b/physionet-django/static/sample/create-course-schema.json
@@ -0,0 +1,28 @@
+{
+ "title": "string",
+ "description": "string",
+ "valid_duration": "string",
+ "version": "string",
+ "modules": [
+ {
+ "contents": [
+ {
+ "body": "string",
+ "order": "integer"
+ }
+ ],
+ "quizzes": [
+ {
+ "question": "string",
+ "order": "integer",
+ "choices": [
+ {
+ "body": "string",
+ "is_correct": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/physionet-django/static/sample/example-course-create.json b/physionet-django/static/sample/example-course-create.json
new file mode 100644
index 0000000000..1661b7a70c
--- /dev/null
+++ b/physionet-django/static/sample/example-course-create.json
@@ -0,0 +1,43 @@
+{
+ "title": "Course 1",
+ "description": "
Test content description
",
+ "valid_duration": "1095 00:00:00",
+ "version": "1.0.0",
+ "modules": [
+ {
+ "name": "Module 1",
+ "description": "
Module description
",
+ "order": 0,
+ "contents": [
+ {
+ "body": "Hello This is a test
",
+ "order": 0
+ }
+ ],
+ "quizzes": [
+ {
+ "question": "What is the correct answer(choice1)?",
+ "order": 1,
+ "choices": [
+ {
+ "body": "I am a choice1",
+ "is_correct": true
+ },
+ {
+ "body": "I am a choice2",
+ "is_correct": false
+ },
+ {
+ "body": "I am a choice3",
+ "is_correct": false
+ },
+ {
+ "body": "I am a choice4",
+ "is_correct": false
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/physionet-django/static/sample/example-course-update.json b/physionet-django/static/sample/example-course-update.json
new file mode 100644
index 0000000000..bb8eef5b17
--- /dev/null
+++ b/physionet-django/static/sample/example-course-update.json
@@ -0,0 +1,43 @@
+{
+ "title": "Course 1 Updated",
+ "description": "Test content description Updated
",
+ "valid_duration": "1095 00:00:00",
+ "version": "1.5.1",
+ "modules": [
+ {
+ "name": "Module 1 Updated",
+ "description": "Test content description Updated
",
+ "order": 0,
+ "contents": [
+ {
+ "body": "
Hello This is a test
",
+ "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/templates/about/citi_course.html b/physionet-django/templates/about/citi_course.html
index c4c35c326c..c59e316638 100644
--- a/physionet-django/templates/about/citi_course.html
+++ b/physionet-django/templates/about/citi_course.html
@@ -20,6 +20,10 @@
CITI Course Instructions
Once submitted, you should see the 'Data or Specimens Only Research' and 'Conflicts of Interest' modules. Please complete these modules.
+
Please make sure when submitting your CITI training application, that you upload the training report rather than the certificate. You can find your training report under 'Records' at the top of the webpage. From there, you can download your training report by selecting 'View-Print-Share' under Completion Record.
+
+
Click 'view/print' under Completion Record to get the full training report to upload on PhysioNet.
+
{% endblock %}
diff --git a/physionet-django/templates/home.html b/physionet-django/templates/home.html
index bc8215ffda..98d4eab2af 100644
--- a/physionet-django/templates/home.html
+++ b/physionet-django/templates/home.html
@@ -23,7 +23,7 @@
{% for news in front_page_banner %}
- {{ news.title }}
+ {{ news.title }}
{% endfor %}
@@ -70,7 +70,7 @@
{% for news in news_pieces %}
{% include "notification/news_content.html" %}
diff --git a/physionet-django/training/__init__.py b/physionet-django/training/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/physionet-django/training/admin.py b/physionet-django/training/admin.py
new file mode 100644
index 0000000000..0d2876f456
--- /dev/null
+++ b/physionet-django/training/admin.py
@@ -0,0 +1,42 @@
+from django.contrib import admin
+from training import models
+
+
+class ContentBlockInline(admin.StackedInline):
+ model = models.ContentBlock
+ extra = 1
+
+
+class QuizChoiceInline(admin.TabularInline):
+ model = models.QuizChoice
+ extra = 1
+
+
+class QuizInline(admin.StackedInline):
+ model = models.Quiz
+ inlines = [QuizChoiceInline, ]
+ extra = 1
+
+
+class ModuleInline(admin.StackedInline):
+ model = models.Module
+ inlines = [ContentBlockInline, QuizInline]
+ extra = 1
+
+
+@admin.register(models.Quiz)
+class QuizAdmin(admin.ModelAdmin):
+ list_display = ('module', 'question', 'order')
+ inlines = [QuizChoiceInline, ]
+
+
+@admin.register(models.Module)
+class ModuleAdmin(admin.ModelAdmin):
+ list_display = ('name', 'course', 'order')
+ inlines = [ContentBlockInline, QuizInline]
+
+
+@admin.register(models.Course)
+class CourseAdmin(admin.ModelAdmin):
+ list_display = ('training_type', 'version')
+ inlines = [ModuleInline]
diff --git a/physionet-django/training/apps.py b/physionet-django/training/apps.py
new file mode 100644
index 0000000000..a94e5537a6
--- /dev/null
+++ b/physionet-django/training/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class TrainingConfig(AppConfig):
+ name = 'training'
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\nNorth 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\nSouth 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.
",
+ "order": 1
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 2,
+ "fields": {
+ "module": 1,
+ "body": "(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\nNorth 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\nSouth 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.
",
+ "order": 2
+ }
+ },
+ {
+ "model": "training.quiz",
+ "pk": 1,
+ "fields": {
+ "module": 1,
+ "question": "Which two continents make up the Americas?(Answer : North America and South America)",
+ "order": 3
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 1,
+ "fields": {
+ "quiz": 1,
+ "body": "Europe and Asia",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 2,
+ "fields": {
+ "quiz": 1,
+ "body": "Africa and Australia",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 3,
+ "fields": {
+ "quiz": 1,
+ "body": "North America and South America",
+ "is_correct": true
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 4,
+ "fields": {
+ "quiz": 1,
+ "body": "Antarctica and South America",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quiz",
+ "pk": 2,
+ "fields": {
+ "module": 1,
+ "question": "What connects North America and South America?(Answer : The Isthmus of Panama)",
+ "order": 4
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 5,
+ "fields": {
+ "quiz": 2,
+ "body": "The Isthmus of Panama",
+ "is_correct": true
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 6,
+ "fields": {
+ "quiz": 2,
+ "body": "The Strait of Gibraltar",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 7,
+ "fields": {
+ "quiz": 2,
+ "body": "The Suez Canal",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 8,
+ "fields": {
+ "quiz": 2,
+ "body": "The Bering Strait",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quiz",
+ "pk": 3,
+ "fields": {
+ "module": 1,
+ "question": "Which is NOT a large country in North America?(Answer : Cuba)",
+ "order": 5
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 9,
+ "fields": {
+ "quiz": 3,
+ "body": "Canada",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 10,
+ "fields": {
+ "quiz": 3,
+ "body": "United States",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 11,
+ "fields": {
+ "quiz": 3,
+ "body": "Mexico",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 12,
+ "fields": {
+ "quiz": 3,
+ "body": "Cuba",
+ "is_correct": true
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 3,
+ "fields": {
+ "module": 1,
+ "body": "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\nThe 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 .
\n",
+ "order": 6
+ }
+ },
+ {
+ "model": "training.quiz",
+ "pk": 4,
+ "fields": {
+ "module": 1,
+ "question": "Which of the following is NOT a country in the Americas? (Answer : India)",
+ "order": 7
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 13,
+ "fields": {
+ "quiz": 4,
+ "body": "Canada",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 14,
+ "fields": {
+ "quiz": 4,
+ "body": "Mexico",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 15,
+ "fields": {
+ "quiz": 4,
+ "body": "Brazil",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 16,
+ "fields": {
+ "quiz": 4,
+ "body": "India",
+ "is_correct": true
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 4,
+ "fields": {
+ "module": 1,
+ "body": "The Americas are a group of countries in the Western Hemisphere. They are also known as America.
\n\nThere are two continents in the Americas: North America and South America. They are connected by the Isthmus of Panama.
",
+ "order": 8
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 5,
+ "fields": {
+ "module": 1,
+ "body": "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.
",
+ "order": 9
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 6,
+ "fields": {
+ "module": 1,
+ "body": "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.
",
+ "order": 10
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 7,
+ "fields": {
+ "module": 1,
+ "body": "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\nIt 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\nThank 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\nEurope 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).
\n",
+ "order": 1
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 9,
+ "fields": {
+ "module": 2,
+ "body": "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.
\n",
+ "order": 2
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 10,
+ "fields": {
+ "module": 2,
+ "body": "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.
\n",
+ "order": 3
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 11,
+ "fields": {
+ "module": 2,
+ "body": "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.
\n",
+ "order": 4
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 12,
+ "fields": {
+ "module": 2,
+ "body": "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.
\n",
+ "order": 5
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 13,
+ "fields": {
+ "module": 2,
+ "body": "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.
\n",
+ "order": 6
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 14,
+ "fields": {
+ "module": 2,
+ "body": "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.
\n",
+ "order": 7
+ }
+ },
+ {
+ "model": "training.contentblock",
+ "pk": 15,
+ "fields": {
+ "module": 2,
+ "body": "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.
\n",
+ "order": 8
+ }
+ },
+ {
+ "model": "training.quiz",
+ "pk": 5,
+ "fields": {
+ "module": 2,
+ "question": "Which of the following countries is NOT located in Europe? (Answer: Nepal)",
+ "order": 9
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 17,
+ "fields": {
+ "quiz": 5,
+ "body": "France",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 18,
+ "fields": {
+ "quiz": 5,
+ "body": "Nepal",
+ "is_correct": true
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 19,
+ "fields": {
+ "quiz": 5,
+ "body": "Spain",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 20,
+ "fields": {
+ "quiz": 5,
+ "body": "Germany",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quiz",
+ "pk": 6,
+ "fields": {
+ "module": 2,
+ "question": "Which of the following countries is NOT located in Africa?(Answer: Nepal)",
+ "order": 10
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 21,
+ "fields": {
+ "quiz": 6,
+ "body": "Egypt",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 22,
+ "fields": {
+ "quiz": 6,
+ "body": "Nepal",
+ "is_correct": true
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 23,
+ "fields": {
+ "quiz": 6,
+ "body": "Zimbabwe",
+ "is_correct": false
+ }
+ },
+ {
+ "model": "training.quizchoice",
+ "pk": 24,
+ "fields": {
+ "quiz": 6,
+ "body": "Cameroon",
+ "is_correct": false
+ }
+ }
+]
diff --git a/physionet-django/training/migrations/0001_initial.py b/physionet-django/training/migrations/0001_initial.py
new file mode 100644
index 0000000000..a5e502ead4
--- /dev/null
+++ b/physionet-django/training/migrations/0001_initial.py
@@ -0,0 +1,128 @@
+# Generated by Django 4.1.7 on 2023-04-11 20:40
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import project.modelcomponents.fields
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('user', '0056_alter_trainingtype_required_field'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Course',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('version', models.FloatField(default=1.0)),
+ ('training_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
+ related_name='courses', to='user.trainingtype')),
+ ],
+ options={
+ 'permissions': [('can_view_course_guidelines', 'Can view course guidelines')],
+ 'default_permissions': ('change',),
+ 'unique_together': {('training_type', 'version')},
+ },
+ ),
+ migrations.CreateModel(
+ name='CourseProgress',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('status', models.CharField(choices=[('IP', 'In Progress'), ('C', 'Completed')], default='IP',
+ max_length=2)),
+ ('started_at', models.DateTimeField(auto_now_add=True)),
+ ('completed_at', models.DateTimeField(auto_now=True)),
+ ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='training.course')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'unique_together': {('user', 'course')},
+ },
+ ),
+ migrations.CreateModel(
+ name='Module',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100)),
+ ('order', models.PositiveIntegerField()),
+ ('description', project.modelcomponents.fields.SafeHTMLField()),
+ ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules',
+ to='training.course')),
+ ],
+ options={
+ 'unique_together': {('course', 'order')},
+ },
+ ),
+ migrations.CreateModel(
+ name='Quiz',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('question', project.modelcomponents.fields.SafeHTMLField()),
+ ('order', models.PositiveIntegerField()),
+ ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes',
+ to='training.module')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='QuizChoice',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('body', models.TextField()),
+ ('is_correct', models.BooleanField(default=False, verbose_name='Correct Choice?')),
+ ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices',
+ to='training.quiz')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ModuleProgress',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('status', models.CharField(choices=[('IP', 'In Progress'), ('C', 'Completed')], default='IP',
+ max_length=2)),
+ ('last_completed_order', models.PositiveIntegerField(default=0, null=True)),
+ ('started_at', models.DateTimeField(blank=True, null=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('course_progress', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
+ related_name='module_progresses', to='training.courseprogress')),
+ ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='training.module')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ContentBlock',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('body', project.modelcomponents.fields.SafeHTMLField()),
+ ('order', models.PositiveIntegerField()),
+ ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contents',
+ to='training.module')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='CompletedQuiz',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('completed_at', models.DateTimeField(blank=True, null=True)),
+ ('module_progress', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
+ related_name='completed_quizzes', to='training.moduleprogress')),
+ ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='training.quiz')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='CompletedContent',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('completed_at', models.DateTimeField(blank=True, null=True)),
+ ('content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
+ to='training.contentblock')),
+ ('module_progress',
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
+ related_name='completed_contents', to='training.moduleprogress')),
+ ],
+ ),
+ ]
diff --git a/physionet-django/training/migrations/0002_alter_course_unique_together_and_more.py b/physionet-django/training/migrations/0002_alter_course_unique_together_and_more.py
new file mode 100644
index 0000000000..dabf8e8076
--- /dev/null
+++ b/physionet-django/training/migrations/0002_alter_course_unique_together_and_more.py
@@ -0,0 +1,48 @@
+# Generated by Django 4.1.10 on 2023-07-31 19:27
+
+from django.db import migrations, models
+import project.validators
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("training", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterUniqueTogether(
+ name="course",
+ unique_together=set(),
+ ),
+ migrations.AlterUniqueTogether(
+ name="courseprogress",
+ unique_together=set(),
+ ),
+ migrations.AlterField(
+ model_name="course",
+ name="version",
+ field=models.CharField(
+ blank=True,
+ default="",
+ max_length=15,
+ validators=[project.validators.validate_version],
+ ),
+ ),
+ migrations.AlterField(
+ model_name="courseprogress",
+ name="completed_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddConstraint(
+ model_name="course",
+ constraint=models.UniqueConstraint(
+ fields=("training_type", "version"), name="unique_course"
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="courseprogress",
+ constraint=models.UniqueConstraint(
+ fields=("user", "course"), name="unique_course_progress"
+ ),
+ ),
+ ]
diff --git a/physionet-django/training/migrations/0003_course_is_active.py b/physionet-django/training/migrations/0003_course_is_active.py
new file mode 100644
index 0000000000..2cd2d27f67
--- /dev/null
+++ b/physionet-django/training/migrations/0003_course_is_active.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.13 on 2023-11-26 22:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('training', '0002_alter_course_unique_together_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='course',
+ name='is_active',
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/physionet-django/training/migrations/0004_course_description_course_title_and_more.py b/physionet-django/training/migrations/0004_course_description_course_title_and_more.py
new file mode 100644
index 0000000000..a397b7c7f4
--- /dev/null
+++ b/physionet-django/training/migrations/0004_course_description_course_title_and_more.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.1.13 on 2024-02-17 04:19
+
+from django.db import migrations, models
+import project.modelcomponents.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('training', '0003_course_is_active'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='course',
+ name='description',
+ field=project.modelcomponents.fields.SafeHTMLField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='course',
+ name='title',
+ field=models.CharField(blank=True, max_length=128, null=True),
+ ),
+ migrations.AddField(
+ model_name='course',
+ name='valid_duration',
+ field=models.DurationField(null=True),
+ ),
+ ]
diff --git a/physionet-django/training/migrations/0005_course_trainings.py b/physionet-django/training/migrations/0005_course_trainings.py
new file mode 100644
index 0000000000..3083193132
--- /dev/null
+++ b/physionet-django/training/migrations/0005_course_trainings.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.1.13 on 2024-03-07 01:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('user', '0060_backfill_trainingtype_slugs'),
+ ('training', '0004_course_description_course_title_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='course',
+ name='trainings',
+ field=models.ManyToManyField(to='user.training'),
+ ),
+ ]
diff --git a/physionet-django/training/migrations/__init__.py b/physionet-django/training/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/physionet-django/training/models.py b/physionet-django/training/models.py
new file mode 100644
index 0000000000..cc75b26847
--- /dev/null
+++ b/physionet-django/training/models.py
@@ -0,0 +1,275 @@
+from token import NUMBER
+from django.db import models
+from django.utils import timezone
+
+from project.modelcomponents.fields import SafeHTMLField
+from project.validators import validate_version
+
+
+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.
+ """
+
+ title = models.CharField(max_length=128, null=True, blank=True)
+ description = SafeHTMLField(null=True, blank=True)
+ valid_duration = models.DurationField(null=True)
+ training_type = models.ForeignKey(
+ "user.TrainingType", on_delete=models.CASCADE, related_name="courses"
+ )
+ trainings = models.ManyToManyField("user.Training")
+ version = models.CharField(
+ max_length=15, default="", blank=True, validators=[validate_version]
+ )
+ is_active = models.BooleanField(default=True)
+
+ class Meta:
+ default_permissions = ("change",)
+ constraints = [
+ models.UniqueConstraint(
+ fields=["training_type", "version"], name="unique_course"
+ )
+ ]
+ permissions = [
+ ("can_view_course_guidelines", "Can view course guidelines"),
+ ]
+
+ def expire_course_version(self, instance, number_of_days):
+ """
+ This method expires the course by setting the is_active field to False and expires all
+ the trainings associated with it.
+ """
+ self.is_active = False
+ # reset the valid_duration to the number of days
+ self.valid_duration = timezone.timedelta(days=number_of_days)
+ self.save()
+
+ def __str__(self):
+ return f"{self.training_type} v{self.version}"
+
+
+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"
+ )
+ order = models.PositiveIntegerField()
+ description = SafeHTMLField()
+
+ class Meta:
+ unique_together = ("course", "order")
+
+ def __str__(self):
+ return self.name
+
+
+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"
+ )
+ order = models.PositiveIntegerField()
+
+
+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"
+ )
+ body = SafeHTMLField()
+ order = models.PositiveIntegerField()
+
+
+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"
+ )
+ body = models.TextField()
+ is_correct = models.BooleanField("Correct Choice?", default=False)
+
+
+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"
+
+ user = models.ForeignKey("user.User", on_delete=models.CASCADE)
+ course = models.ForeignKey("training.Course", on_delete=models.CASCADE)
+ status = models.CharField(
+ max_length=2, choices=Status.choices, default=Status.IN_PROGRESS
+ )
+ started_at = models.DateTimeField(auto_now_add=True)
+ completed_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=["user", "course"], name="unique_course_progress"
+ )
+ ]
+
+ def __str__(self):
+ return f"{self.user.username} - {self.course}"
+
+ def get_next_module(self):
+ if self.status == self.Status.COMPLETED:
+ return None
+
+ next_module = self.module_progresses.filter(
+ status=self.module_progresses.model.Status.IN_PROGRESS
+ ).first()
+ if next_module:
+ return next_module.module
+
+ last_module = (
+ self.module_progresses.filter(
+ status=self.module_progresses.model.Status.COMPLETED
+ )
+ .order_by("-last_completed_order")
+ .first()
+ )
+ if last_module:
+ return (
+ self.course.modules.filter(order__gt=last_module.module.order)
+ .order_by("order")
+ .first()
+ )
+
+ return self.course.modules.first()
+
+
+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"
+
+ course_progress = models.ForeignKey(
+ "training.CourseProgress",
+ on_delete=models.CASCADE,
+ related_name="module_progresses",
+ )
+ module = models.ForeignKey("training.Module", on_delete=models.CASCADE)
+ status = models.CharField(
+ max_length=2, choices=Status.choices, default=Status.IN_PROGRESS
+ )
+ last_completed_order = models.PositiveIntegerField(null=True, default=0)
+ started_at = models.DateTimeField(null=True, blank=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self):
+ return f"{self.course_progress.user.username} - {self.module}"
+
+
+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,
+ related_name="completed_contents",
+ )
+ content = models.ForeignKey("training.ContentBlock", on_delete=models.CASCADE)
+ completed_at = models.DateTimeField(null=True, blank=True)
+
+ def __str__(self):
+ return f"{self.module_progress.course_progress.user.username} - {self.content}"
+
+
+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,
+ related_name="completed_quizzes",
+ )
+ quiz = models.ForeignKey("training.Quiz", on_delete=models.CASCADE)
+ completed_at = models.DateTimeField(null=True, blank=True)
+
+ def __str__(self):
+ return f"{self.module_progress.course_progress.user.username} - {self.quiz}"
diff --git a/physionet-django/training/serializers.py b/physionet-django/training/serializers.py
new file mode 100644
index 0000000000..044fdb7b6a
--- /dev/null
+++ b/physionet-django/training/serializers.py
@@ -0,0 +1,121 @@
+import datetime
+from rest_framework import serializers
+from django.db import transaction
+
+from training.models import Course, Module, Quiz, QuizChoice, ContentBlock
+from user.models import Training, TrainingType
+from user.enums import RequiredField
+
+
+class QuizChoiceSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = QuizChoice
+ fields = ['body', 'is_correct']
+ read_only_fields = ['id', 'quiz']
+
+
+class QuizSerializer(serializers.ModelSerializer):
+ choices = QuizChoiceSerializer(many=True)
+
+ class Meta:
+ model = Quiz
+ fields = ['question', 'order', 'choices']
+ read_only_fields = ['id', 'module']
+
+
+class ContentBlockSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = ContentBlock
+ fields = ['body', 'order']
+ read_only_fields = ['id', 'module']
+
+
+class ModuleSerializer(serializers.ModelSerializer):
+ quizzes = QuizSerializer(many=True)
+ contents = ContentBlockSerializer(many=True)
+
+ class Meta:
+ model = Module
+ fields = ['name', 'description', 'order', 'contents', 'quizzes']
+ read_only_fields = ['id', 'course']
+
+
+def create_quizzes(module_instance, quizzes_data):
+ choice_bulk = []
+ for quiz in quizzes_data:
+ choices = quiz.pop('choices')
+
+ quiz['module'] = module_instance
+ q = Quiz(**quiz)
+ q.save()
+
+ for choice in choices:
+ choice['quiz'] = q
+ choice_bulk.append(QuizChoice(**choice))
+
+ QuizChoice.objects.bulk_create(choice_bulk)
+
+
+def create_contentblocks(module_instance, content_data):
+ content_bulk = []
+ for content in content_data:
+ content['module'] = module_instance
+ content_bulk.append(ContentBlock(**content))
+ ContentBlock.objects.bulk_create(content_bulk)
+
+
+def create_modules(course_instance, modules_data):
+ for module_data in modules_data:
+ quizzes = module_data.pop('quizzes')
+ contents = module_data.pop('contents')
+
+ module_data['course'] = course_instance
+ module_instance = Module.objects.create(**module_data)
+
+ create_quizzes(module_instance, quizzes)
+ create_contentblocks(module_instance, contents)
+
+
+class CourseSerializer(serializers.ModelSerializer):
+ modules = ModuleSerializer(many=True)
+
+ class Meta:
+ model = Course
+ fields = ['title', 'description', 'valid_duration', 'version', 'modules']
+ read_only_fields = ['id', 'training_type']
+
+ def update(self, instance, validated_data):
+ with transaction.atomic():
+ course = validated_data
+ modules = course.pop('modules')
+ course['training_type'] = instance.training_type
+
+ course_instance = Course.objects.create(**course)
+ create_modules(course_instance, modules)
+
+ return course_instance
+
+ def create(self, validated_data):
+ with transaction.atomic():
+ course = validated_data
+ modules = course.pop('modules')
+ training_type_name = validated_data['title']
+ training_type_description = validated_data['description']
+ training_type_valid_duration = validated_data['valid_duration']
+ training_type_required_field = RequiredField.PLATFORM
+
+ training_type_instance = TrainingType.objects.create(
+ name=training_type_name,
+ description=training_type_description,
+ valid_duration=training_type_valid_duration,
+ required_field=training_type_required_field
+ )
+
+ course['training_type'] = training_type_instance
+ course_instance = Course.objects.create(**course)
+
+ create_modules(course_instance, modules)
+
+ return course_instance
diff --git a/physionet-django/training/templates/training/email/training_expiry_notification.html b/physionet-django/training/templates/training/email/training_expiry_notification.html
new file mode 100644
index 0000000000..3155d5d553
--- /dev/null
+++ b/physionet-django/training/templates/training/email/training_expiry_notification.html
@@ -0,0 +1,11 @@
+{% load i18n %}{% autoescape off %}{% filter wordwrap:70 %}
+Dear {{ name }},
+
+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 %}
diff --git a/physionet-django/training/urls.py b/physionet-django/training/urls.py
new file mode 100644
index 0000000000..1e3749899d
--- /dev/null
+++ b/physionet-django/training/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+from training import views
+
+
+urlpatterns = [
+
+]
diff --git a/physionet-django/training/views.py b/physionet-django/training/views.py
new file mode 100644
index 0000000000..d63ba9ccee
--- /dev/null
+++ b/physionet-django/training/views.py
@@ -0,0 +1,177 @@
+from calendar import c
+from hmac import new
+import json
+import operator
+from itertools import chain
+import re
+
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required, permission_required
+from django.db import transaction
+from django.db.models import Prefetch
+from django.http import JsonResponse
+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, is_version_greater
+
+from rest_framework.parsers import JSONParser
+
+from user.models import Training, TrainingType, TrainingQuestion, RequiredField
+from user.enums import TrainingStatus
+
+from training.models import Course, Quiz, QuizChoice, ContentBlock
+from training.models import CourseProgress, ModuleProgress, CompletedContent, CompletedQuiz
+from training.serializers import CourseSerializer
+from console.views import console_permission_required
+
+
+@permission_required('training.change_course', raise_exception=True)
+@console_permission_required('training.change_course')
+def courses(request):
+ """
+ View function for managing courses.
+ Allows creation and updating of courses for a given training type.
+ """
+ if request.POST:
+
+ json_file = request.FILES.get("json_file", "")
+
+ if not json_file.name.endswith('.json'):
+ messages.warning(request, 'File is not of JSON type')
+ return redirect("courses")
+
+ # Checking if the content of the JSON file is properly formatted and according to the schema
+ try:
+ file_data = JSONParser().parse(json_file.file)
+ except json.decoder.JSONDecodeError:
+ messages.error(request, 'JSON file is not properly formatted.')
+ return redirect("courses")
+
+ serializer = CourseSerializer(data=file_data, partial=True)
+
+ if serializer.is_valid(raise_exception=False):
+ serializer.save()
+ messages.success(request, 'Course created successfully.')
+ else:
+ messages.error(request, serializer.errors)
+
+ return redirect("courses")
+
+ training_types = TrainingType.objects.filter(required_field=RequiredField.PLATFORM)
+ return render(
+ request,
+ 'console/training_type/index.html',
+ {
+ 'training_types': training_types,
+ 'training_type_nav': True,
+ })
+
+
+@permission_required('training.change_course', raise_exception=True)
+@console_permission_required('training.change_course')
+def course_details(request, training_slug):
+ """
+ View function for managing courses.
+ Allows managing the version of the courses for a given training type.
+ Allows expiring the specific version of the course.
+ """
+ if request.POST:
+ training_type = get_object_or_404(TrainingType, slug=training_slug)
+ json_file = request.FILES.get("json_file", "")
+
+ if not json_file.name.endswith('.json'):
+ messages.warning(request, 'File is not of JSON type')
+ return redirect("courses")
+
+ # Checking if the content of the JSON file is properly formatted and according to the schema
+ try:
+ file_data = JSONParser().parse(json_file.file)
+ except json.decoder.JSONDecodeError:
+ messages.error(request, 'JSON file is not properly formatted.')
+ return redirect("courses")
+
+ # Checking if the Training type with the same version already exists
+ existing_course = Course.objects.filter(training_type=training_type)
+ if existing_course.exists():
+ latest_course = existing_course.order_by('-version').first()
+ latest_course_version = existing_course.order_by('-version').first().version
+ new_course_version = file_data['version']
+ # checking if the new course file has a valid 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 not is_version_greater(new_course_version, latest_course_version):
+ messages.error(request, 'Version number should be greater than the existing version.')
+ else:
+ serializer = CourseSerializer(latest_course, data=file_data, partial=True)
+ if serializer.is_valid(raise_exception=False):
+ serializer.save()
+ messages.success(request, 'Course updated successfully.')
+
+ return redirect("course_details", training_slug)
+
+ training_type = get_object_or_404(TrainingType, slug=training_slug)
+ active_course_versions = Course.objects.filter(training_type=training_type, is_active=True).order_by('-version')
+ inactive_course_versions = Course.objects.filter(training_type=training_type, is_active=False).order_by('-version')
+ return render(
+ request,
+ 'console/training_type/course_details.html',
+ {
+ 'training_type': training_type,
+ 'active_course_versions': active_course_versions,
+ 'inactive_course_versions': inactive_course_versions,
+ 'training_type_nav': True,
+ })
+
+
+@permission_required('training.change_course', raise_exception=True)
+@console_permission_required('training.change_course')
+def expire_course(request, training_slug, version):
+ """
+ This view takes a primary key and a version number as input parameters,
+ and expires the course with the specified primary key and version number.
+ """
+ course = Course.objects.filter(training_type__slug=training_slug, version=version).first()
+ expiry_date = request.POST.get('expiry_date')
+ if not course:
+ messages.error(request, 'Course not found')
+ return redirect('courses')
+ if not expiry_date:
+ messages.error(request, 'Expiry Date is required')
+ return redirect('course_details', training_slug)
+ # Checking if the expiry date is greater than the current date
+ expiry_date_tz = timezone.make_aware(timezone.datetime.strptime(expiry_date, '%Y-%m-%d'))
+ if expiry_date_tz < timezone.now():
+ messages.error(request, 'Expiry Date should be greater than the current date')
+ return redirect('course_details', training_slug)
+ # Calculating the number of days between the current date and the expiry date
+ number_of_days = (expiry_date_tz - timezone.now()).days
+ course.expire_course_version(course.training_type, int(number_of_days))
+ messages.success(request, 'Course expired successfully.')
+ return redirect('course_details', training_slug)
+
+
+@permission_required('training.change_course', raise_exception=True)
+@console_permission_required('training.change_course')
+def download_course(request, training_slug, 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, slug=training_slug)
+ course = training_type.courses.filter(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 = 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'
+ return response
diff --git a/physionet-django/user/enums.py b/physionet-django/user/enums.py
index b3d32278f2..004ebbc96e 100644
--- a/physionet-django/user/enums.py
+++ b/physionet-django/user/enums.py
@@ -17,6 +17,7 @@ def choices(cls):
class RequiredField(IntEnum):
DOCUMENT = 0
URL = 1
+ PLATFORM = 2
@classmethod
def choices(cls):
diff --git a/physionet-django/user/fixtures/demo-training-type.json b/physionet-django/user/fixtures/demo-training-type.json
index 35b34ca87d..b8ac562adc 100644
--- a/physionet-django/user/fixtures/demo-training-type.json
+++ b/physionet-django/user/fixtures/demo-training-type.json
@@ -7,6 +7,7 @@
"description": "The CITI Data or Specimens only course covers important aspects of research with human participant data. Modules covered include:
\n\n\n\tDefining Research with Human Subjects \n\tPrivacy and Confidentiality \n\tAssessing Risk \n\tResearch with Children \n\tInternational Research \n\tHistory and Ethical Principles \n\tRegulations and Process \n\tSBR Methodologies in Biomedical Research \n\tGenetics Research \n\tRecords-Based Research \n\tPopulations in Research Requiring Additional Considerations and/or Protections \n\tHIPAA and Human Subjects Research \n\tConflicts of Interest in Research Involving Human Subjects \n ",
"home_page": "/about/citi-course/",
"valid_duration": "1095 00:00:00",
+ "slug": "citi-data-or-specimens-only-research",
"required_field": 0,
"questions": [
1,
@@ -17,6 +18,25 @@
]
}
},
+ {
+ "model": "user.trainingtype",
+ "pk": 2,
+ "fields": {
+ "name": "World 101: Introduction to Continents and Countries",
+ "slug": "world-101-introduction-to-continents-and-countries",
+ "description": "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\nWhat You Will Learn:
\n\n\n\tThe names and locations of the seven continents \n\tKey countries and their capitals on each continent \n\tBasic geographical features and landmarks \n\tCultural and historical highlights of different regions \n \n\nPrerequisites:
\n\n\n\tNo prior knowledge is required \n\tAn interest in geography and world cultures is recommended \n \n\nDon’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\nContact: For more information or to register for this training, please contact us at training@discoveringtheworld.com or call us at 555-1234.
\n",
+ "home_page": "/",
+ "valid_duration": "1095 00:00:00",
+ "required_field": 2,
+ "questions": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5
+ ]
+ }
+ },
{
"model": "user.question",
"pk": 1,
diff --git a/physionet-django/user/fixtures/demo-user.json b/physionet-django/user/fixtures/demo-user.json
index 23fc251e0d..fa1eb2cb39 100644
--- a/physionet-django/user/fixtures/demo-user.json
+++ b/physionet-django/user/fixtures/demo-user.json
@@ -14389,11 +14389,6 @@
"project",
"activeproject"
],
- [
- "change_archivedproject",
- "project",
- "archivedproject"
- ],
[
"add_dua",
"project",
@@ -14463,6 +14458,16 @@
"view_redirect",
"redirects",
"redirect"
+ ],
+ [
+ "change_course",
+ "training",
+ "course"
+ ],
+ [
+ "can_view_course_guidelines",
+ "training",
+ "course"
]
]
}
@@ -14482,11 +14487,6 @@
"project",
"activeproject"
],
- [
- "change_archivedproject",
- "project",
- "archivedproject"
- ],
[
"can_view_access_logs",
"project",
@@ -14580,6 +14580,11 @@
"can_view_admin_console",
"user",
"user"
+ ],
+ [
+ "can_view_course_guidelines",
+ "training",
+ "course"
]
]
}
diff --git a/physionet-django/user/forms.py b/physionet-django/user/forms.py
index 5ca6e18c13..dc9e7d08a6 100644
--- a/physionet-django/user/forms.py
+++ b/physionet-django/user/forms.py
@@ -542,7 +542,7 @@ def clean_reference_name(self):
def clean_reference_email(self):
reference_email = self.cleaned_data.get('reference_email')
if reference_email:
- if reference_email in self.user.get_emails():
+ if reference_email.lower() in [email.lower() for email in self.user.get_emails()]:
raise forms.ValidationError("""You can not put yourself
as a reference.""")
else:
diff --git a/physionet-django/user/management/commands/resetdb.py b/physionet-django/user/management/commands/resetdb.py
index 96a8ebdfa5..29238248cb 100644
--- a/physionet-django/user/management/commands/resetdb.py
+++ b/physionet-django/user/management/commands/resetdb.py
@@ -19,7 +19,7 @@
from django.core.management.base import BaseCommand
from lightwave.views import DBCAL_FILE
-from project.models import ActiveProject, PublishedProject, ArchivedProject
+from project.models import ActiveProject, PublishedProject
from user.models import User, CredentialApplication
diff --git a/physionet-django/user/migrations/0056_alter_trainingtype_required_field.py b/physionet-django/user/migrations/0056_alter_trainingtype_required_field.py
new file mode 100644
index 0000000000..1328047fe3
--- /dev/null
+++ b/physionet-django/user/migrations/0056_alter_trainingtype_required_field.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.1.5 on 2023-02-18 02:12
+
+from django.db import migrations, models
+import user.enums
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('user', '0055_auto_20230330_1723'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='trainingtype',
+ name='required_field',
+ field=models.PositiveSmallIntegerField(
+ choices=[(0, 'DOCUMENT'), (1, 'URL'), (2, 'PLATFORM')],
+ default=user.enums.RequiredField['DOCUMENT']),
+ ),
+ ]
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 = [
+ ]
diff --git a/physionet-django/user/migrations/0058_merge_20231127_1642.py b/physionet-django/user/migrations/0058_merge_20231127_1642.py
new file mode 100644
index 0000000000..7353589c68
--- /dev/null
+++ b/physionet-django/user/migrations/0058_merge_20231127_1642.py
@@ -0,0 +1,14 @@
+# Generated by Django 4.1.13 on 2023-11-27 21:42
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('user', '0057_alter_cloudinformation_aws_id'),
+ ('user', '0057_merge_20230828_1158'),
+ ]
+
+ operations = [
+ ]
diff --git a/physionet-django/user/migrations/0059_trainingtype_slug.py b/physionet-django/user/migrations/0059_trainingtype_slug.py
new file mode 100644
index 0000000000..dc2ec6ab58
--- /dev/null
+++ b/physionet-django/user/migrations/0059_trainingtype_slug.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.13 on 2024-03-04 18:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("user", "0058_merge_20231127_1642"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="trainingtype",
+ name="slug",
+ field=models.SlugField(max_length=128, null=True, unique=True),
+ ),
+ ]
diff --git a/physionet-django/user/migrations/0060_backfill_trainingtype_slugs.py b/physionet-django/user/migrations/0060_backfill_trainingtype_slugs.py
new file mode 100644
index 0000000000..e8d2187b68
--- /dev/null
+++ b/physionet-django/user/migrations/0060_backfill_trainingtype_slugs.py
@@ -0,0 +1,33 @@
+# Generated by Django 4.1.13 on 2024-03-04 18:15
+
+from django.db import migrations
+from django.utils.text import slugify
+
+
+def generate_slugs(apps, schema_editor):
+ TrainingType = apps.get_model("user", "TrainingType")
+ for training_type in TrainingType.objects.all():
+ if not training_type.slug:
+ slug = slugify(training_type.name)
+ unique_slug = slug
+ num = 1
+ while TrainingType.objects.filter(slug=unique_slug).exists():
+ unique_slug = '{}-{}'.format(slug, num)
+ num += 1
+ training_type.slug = unique_slug
+ training_type.save()
+
+
+def migrate_backward(apps, schema_editor):
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("user", "0059_trainingtype_slug"),
+ ]
+
+ operations = [
+ migrations.RunPython(generate_slugs, migrate_backward),
+ ]
diff --git a/physionet-django/user/models.py b/physionet-django/user/models.py
index 2ad2e396f2..10de5ac0be 100644
--- a/physionet-django/user/models.py
+++ b/physionet-django/user/models.py
@@ -18,16 +18,18 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
+from django.utils.text import slugify
from django.utils.crypto import constant_time_compare
from django.utils.translation import gettext as _
-from project.validators import validate_version
from project.modelcomponents.access import AccessPolicy
from project.modelcomponents.fields import SafeHTMLField
+from project.validators import validate_version
from user import validators
from user.userfiles import UserFiles
from user.enums import TrainingStatus, RequiredField
from user.managers import TrainingQuerySet
+from training.models import Course
logger = logging.getLogger(__name__)
@@ -1101,10 +1103,35 @@ def __str__(self):
class TrainingType(models.Model):
+ """Represents a type of training. For example, CITI training -
+ which can be on-platform or off-platform.
+ The training type if off-platform, will have a set of questions that
+ the admin uses to make sure that the training was properly validated.
+ If the training type is on-platform, there will be attached
+ courses. Each Course is a version of the training type, and has modules,
+ which the user can complete to get the accredition (Training) for
+ the particular training type.
+
+ Attributes:
+ name (str): The name of the training type.
+ description (SafeHTMLField): The description of the training type.
+ valid_duration (DurationField, optional): The valid duration of the training type.
+ questions (ManyToManyField): The questions associated with the training type.
+ required_field (PositiveSmallIntegerField): The required field for the training type.
+ home_page (URLField, optional): The home page URL for the training type.
+
+ Meta:
+ default_permissions (tuple): The default permissions for the training type.
+ permissions (list): The additional permissions for the training type.
+
+ Methods:
+ __str__(): Returns a string representation of the training type.
+ """
name = models.CharField(max_length=128)
description = SafeHTMLField()
valid_duration = models.DurationField(null=True)
questions = models.ManyToManyField(Question, related_name='training_types')
+ slug = models.SlugField(max_length=128, null=True, unique=True)
required_field = models.PositiveSmallIntegerField(choices=RequiredField.choices(), default=RequiredField.DOCUMENT)
home_page = models.URLField(blank=True)
@@ -1117,6 +1144,17 @@ class Meta:
def __str__(self):
return self.name
+ def save(self, *args, **kwargs):
+ if not self.slug:
+ slug = slugify(self.name)
+ unique_slug = slug
+ num = 1
+ while TrainingType.objects.filter(slug=unique_slug).exists():
+ unique_slug = f'{slug}-{num}'
+ num += 1
+ self.slug = unique_slug
+ return super().save(*args, **kwargs)
+
class TrainingRegex(models.Model):
name = models.CharField(max_length=48)
@@ -1179,20 +1217,28 @@ def is_withdrawn(self):
def is_valid(self):
if self.status == TrainingStatus.ACCEPTED:
- if not self.training_type.valid_duration:
- return True
+ if self.training_type.required_field == RequiredField.PLATFORM:
+ associated_course = Course.objects.filter(training=self).first()
+ return self.process_datetime + associated_course.valid_duration >= timezone.now()
else:
- return self.process_datetime + self.training_type.valid_duration >= timezone.now()
+ if not self.training_type.valid_duration:
+ return False
+ else:
+ return self.process_datetime + self.training_type.valid_duration >= timezone.now()
def is_expired(self):
"""checks if it has exceeded the valid period (process_time + duration)
if no valid duration, its not expired.
"""
if self.status == TrainingStatus.ACCEPTED:
- if not self.training_type.valid_duration:
- return False
+ if self.training_type.required_field == RequiredField.PLATFORM:
+ associated_course = Course.objects.filter(training=self).first()
+ return self.process_datetime + associated_course.valid_duration < timezone.now()
else:
- return self.process_datetime + self.training_type.valid_duration < timezone.now()
+ if not self.training_type.valid_duration:
+ return False
+ else:
+ return self.process_datetime + self.training_type.valid_duration < timezone.now()
def is_rejected(self):
return self.status == TrainingStatus.REJECTED
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..4607ce8c1c 100644
--- a/physionet-django/user/views.py
+++ b/physionet-django/user/views.py
@@ -1,7 +1,7 @@
import logging
import os
import pdb
-from datetime import datetime
+from datetime import datetime, date, timedelta
import django.contrib.auth.views as auth_views
import pytz
@@ -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,
)
@@ -58,7 +60,7 @@
)
from user.userfiles import UserFiles
from physionet.models import StaticPage
-
+from django.db.models import F
logger = logging.getLogger(__name__)
@@ -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):
@@ -754,6 +770,19 @@ def edit_training(request):
else:
training_form = forms.TrainingForm(user=request.user)
+ expiring_trainings = Training.objects.filter(
+ user=request.user,
+ process_datetime__lte=date.today() - F('training_type__valid_duration') + timedelta(days=30),
+ process_datetime__gt=date.today() - F('training_type__valid_duration')
+ )
+ if expiring_trainings:
+ for training in expiring_trainings:
+ days_until_expiry = (
+ training.process_datetime.date() + training.training_type.valid_duration - date.today()
+ ).days
+ message = f"Your {training.training_type.name} training will expire in {days_until_expiry} days."
+ messages.warning(request, message)
+
return render(
request,
"user/edit_training.html",
diff --git a/poetry.lock b/poetry.lock
index 9c88d618de..645fc40b23 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,16 +1,20 @@
-# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry and should not be changed by hand.
[[package]]
name = "asgiref"
-version = "3.5.2"
+version = "3.7.2"
description = "ASGI specs, helper code, and adapters"
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
- {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"},
- {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"},
+ {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"},
+ {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"},
]
+[package.dependencies]
+typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""}
+
[package.extras]
tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
@@ -18,6 +22,7 @@ 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 = [
@@ -34,6 +39,7 @@ webencodings = "*"
name = "boto3"
version = "1.28.53"
description = "The AWS SDK for Python"
+category = "main"
optional = false
python-versions = ">= 3.7"
files = [
@@ -53,6 +59,7 @@ 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 = [
@@ -72,6 +79,7 @@ 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 = [
@@ -83,6 +91,7 @@ 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 = [
@@ -94,6 +103,7 @@ files = [
name = "cffi"
version = "1.15.1"
description = "Foreign Function Interface for Python calling C code."
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -170,6 +180,7 @@ pycparser = "*"
name = "chardet"
version = "3.0.4"
description = "Universal encoding detector for Python 2 and 3"
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -181,6 +192,7 @@ 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 = [
@@ -195,6 +207,7 @@ unicode-backport = ["unicodedata2"]
name = "coverage"
version = "7.2.3"
description = "Code coverage measurement for Python"
+category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -256,34 +269,35 @@ toml = ["tomli"]
[[package]]
name = "cryptography"
-version = "41.0.4"
+version = "41.0.6"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
- {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:80907d3faa55dc5434a16579952ac6da800935cd98d14dbd62f6f042c7f5e839"},
- {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35c00f637cd0b9d5b6c6bd11b6c3359194a8eba9c46d4e875a3660e3b400005f"},
- {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cecfefa17042941f94ab54f769c8ce0fe14beff2694e9ac684176a2535bf9714"},
- {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e40211b4923ba5a6dc9769eab704bdb3fbb58d56c5b336d30996c24fcf12aadb"},
- {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:23a25c09dfd0d9f28da2352503b23e086f8e78096b9fd585d1d14eca01613e13"},
- {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2ed09183922d66c4ec5fdaa59b4d14e105c084dd0febd27452de8f6f74704143"},
- {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5a0f09cefded00e648a127048119f77bc2b2ec61e736660b5789e638f43cc397"},
- {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9eeb77214afae972a00dee47382d2591abe77bdae166bda672fb1e24702a3860"},
- {file = "cryptography-41.0.4-cp37-abi3-win32.whl", hash = "sha256:3b224890962a2d7b57cf5eeb16ccaafba6083f7b811829f00476309bce2fe0fd"},
- {file = "cryptography-41.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:c880eba5175f4307129784eca96f4e70b88e57aa3f680aeba3bab0e980b0f37d"},
- {file = "cryptography-41.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:004b6ccc95943f6a9ad3142cfabcc769d7ee38a3f60fb0dddbfb431f818c3a67"},
- {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:86defa8d248c3fa029da68ce61fe735432b047e32179883bdb1e79ed9bb8195e"},
- {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:37480760ae08065437e6573d14be973112c9e6dcaf5f11d00147ee74f37a3829"},
- {file = "cryptography-41.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b5f4dfe950ff0479f1f00eda09c18798d4f49b98f4e2006d644b3301682ebdca"},
- {file = "cryptography-41.0.4-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7e53db173370dea832190870e975a1e09c86a879b613948f09eb49324218c14d"},
- {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5b72205a360f3b6176485a333256b9bcd48700fc755fef51c8e7e67c4b63e3ac"},
- {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:93530900d14c37a46ce3d6c9e6fd35dbe5f5601bf6b3a5c325c7bffc030344d9"},
- {file = "cryptography-41.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efc8ad4e6fc4f1752ebfb58aefece8b4e3c4cae940b0994d43649bdfce8d0d4f"},
- {file = "cryptography-41.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3391bd8e6de35f6f1140e50aaeb3e2b3d6a9012536ca23ab0d9c35ec18c8a91"},
- {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0d9409894f495d465fe6fda92cb70e8323e9648af912d5b9141d616df40a87b8"},
- {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ac4f9ead4bbd0bc8ab2d318f97d85147167a488be0e08814a37eb2f439d5cf6"},
- {file = "cryptography-41.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:047c4603aeb4bbd8db2756e38f5b8bd7e94318c047cfe4efeb5d715e08b49311"},
- {file = "cryptography-41.0.4.tar.gz", hash = "sha256:7febc3094125fc126a7f6fb1f420d0da639f3f32cb15c8ff0dc3997c4549f51a"},
+ {file = "cryptography-41.0.6-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:0f27acb55a4e77b9be8d550d762b0513ef3fc658cd3eb15110ebbcbd626db12c"},
+ {file = "cryptography-41.0.6-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ae236bb8760c1e55b7a39b6d4d32d2279bc6c7c8500b7d5a13b6fb9fc97be35b"},
+ {file = "cryptography-41.0.6-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afda76d84b053923c27ede5edc1ed7d53e3c9f475ebaf63c68e69f1403c405a8"},
+ {file = "cryptography-41.0.6-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da46e2b5df770070412c46f87bac0849b8d685c5f2679771de277a422c7d0b86"},
+ {file = "cryptography-41.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ff369dd19e8fe0528b02e8df9f2aeb2479f89b1270d90f96a63500afe9af5cae"},
+ {file = "cryptography-41.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b648fe2a45e426aaee684ddca2632f62ec4613ef362f4d681a9a6283d10e079d"},
+ {file = "cryptography-41.0.6-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5daeb18e7886a358064a68dbcaf441c036cbdb7da52ae744e7b9207b04d3908c"},
+ {file = "cryptography-41.0.6-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:068bc551698c234742c40049e46840843f3d98ad7ce265fd2bd4ec0d11306596"},
+ {file = "cryptography-41.0.6-cp37-abi3-win32.whl", hash = "sha256:2132d5865eea673fe6712c2ed5fb4fa49dba10768bb4cc798345748380ee3660"},
+ {file = "cryptography-41.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:48783b7e2bef51224020efb61b42704207dde583d7e371ef8fc2a5fb6c0aabc7"},
+ {file = "cryptography-41.0.6-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8efb2af8d4ba9dbc9c9dd8f04d19a7abb5b49eab1f3694e7b5a16a5fc2856f5c"},
+ {file = "cryptography-41.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c5a550dc7a3b50b116323e3d376241829fd326ac47bc195e04eb33a8170902a9"},
+ {file = "cryptography-41.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:85abd057699b98fce40b41737afb234fef05c67e116f6f3650782c10862c43da"},
+ {file = "cryptography-41.0.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f39812f70fc5c71a15aa3c97b2bbe213c3f2a460b79bd21c40d033bb34a9bf36"},
+ {file = "cryptography-41.0.6-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:742ae5e9a2310e9dade7932f9576606836ed174da3c7d26bc3d3ab4bd49b9f65"},
+ {file = "cryptography-41.0.6-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:35f3f288e83c3f6f10752467c48919a7a94b7d88cc00b0668372a0d2ad4f8ead"},
+ {file = "cryptography-41.0.6-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4d03186af98b1c01a4eda396b137f29e4e3fb0173e30f885e27acec8823c1b09"},
+ {file = "cryptography-41.0.6-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b27a7fd4229abef715e064269d98a7e2909ebf92eb6912a9603c7e14c181928c"},
+ {file = "cryptography-41.0.6-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:398ae1fc711b5eb78e977daa3cbf47cec20f2c08c5da129b7a296055fbb22aed"},
+ {file = "cryptography-41.0.6-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7e00fb556bda398b99b0da289ce7053639d33b572847181d6483ad89835115f6"},
+ {file = "cryptography-41.0.6-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:60e746b11b937911dc70d164060d28d273e31853bb359e2b2033c9e93e6f3c43"},
+ {file = "cryptography-41.0.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3288acccef021e3c3c10d58933f44e8602cf04dba96d9796d70d537bb2f4bbc4"},
+ {file = "cryptography-41.0.6.tar.gz", hash = "sha256:422e3e31d63743855e43e5a6fcc8b4acab860f560f9321b0ee6269cc7ed70cc3"},
]
[package.dependencies]
@@ -299,37 +313,21 @@ ssh = ["bcrypt (>=3.1.5)"]
test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test-randomorder = ["pytest-randomly"]
-[[package]]
-name = "deprecated"
-version = "1.2.13"
-description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-files = [
- {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"},
- {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"},
-]
-
-[package.dependencies]
-wrapt = ">=1.10,<2"
-
-[package.extras]
-dev = ["PyTest", "PyTest (<5)", "PyTest-Cov", "PyTest-Cov (<2.6)", "bump2version (<1)", "configparser (<5)", "importlib-metadata (<3)", "importlib-resources (<4)", "sphinx (<2)", "sphinxcontrib-websupport (<2)", "tox", "zipp (<2)"]
-
[[package]]
name = "django"
-version = "4.1.10"
+version = "4.2.11"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
+category = "main"
optional = false
python-versions = ">=3.8"
files = [
- {file = "Django-4.1.10-py3-none-any.whl", hash = "sha256:26d0260c2fb8121009e62ffc548b2398dea2522b6454208a852fb0ef264c206c"},
- {file = "Django-4.1.10.tar.gz", hash = "sha256:56343019a9fd839e2e5bf203daf45f25af79d5bffa4c71d56eae4f4404d82ade"},
+ {file = "Django-4.2.11-py3-none-any.whl", hash = "sha256:ddc24a0a8280a0430baa37aff11f28574720af05888c62b7cfe71d219f4599d3"},
+ {file = "Django-4.2.11.tar.gz", hash = "sha256:6e6ff3db2d8dd0c986b4eec8554c8e4f919b5c1ff62a5b4390c17aff2ed6e5c4"},
]
[package.dependencies]
-asgiref = ">=3.5.2,<4"
-sqlparse = ">=0.2.2"
+asgiref = ">=3.6.0,<4"
+sqlparse = ">=0.3.1"
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
@@ -340,6 +338,7 @@ bcrypt = ["bcrypt"]
name = "django-autocomplete-light"
version = "3.9.4"
description = "Fresh autocompletes for Django"
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -359,6 +358,7 @@ 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 = [
@@ -374,6 +374,7 @@ six = "*"
name = "django-ckeditor"
version = "6.5.1"
description = "Django admin CKEditor integration."
+category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -389,6 +390,7 @@ 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 = [
@@ -399,10 +401,26 @@ files = [
[package.dependencies]
Django = ">=3.2"
+[[package]]
+name = "django-coverage-plugin"
+version = "3.1.0"
+description = "Django template coverage.py plugin"
+category = "dev"
+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 = [
@@ -418,6 +436,7 @@ 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 = [
@@ -435,6 +454,7 @@ tests = ["coverage"]
name = "django-oauth-toolkit"
version = "2.2.0"
description = "OAuth2 Provider for Django"
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -448,10 +468,51 @@ jwcrypto = ">=0.8.0"
oauthlib = ">=3.1.0"
requests = ">=2.13.0"
+[[package]]
+name = "django-picklefield"
+version = "3.1"
+description = "Pickled object field for Django"
+category = "main"
+optional = false
+python-versions = ">=3"
+files = [
+ {file = "django-picklefield-3.1.tar.gz", hash = "sha256:c786cbeda78d6def2b43bff4840d19787809c8909f7ad683961703060398d356"},
+ {file = "django_picklefield-3.1-py3-none-any.whl", hash = "sha256:d77c504df7311e8ec14e8b779f10ca6fec74de6c7f8e2c136e1ef60cf955125d"},
+]
+
+[package.dependencies]
+Django = ">=3.2"
+
+[package.extras]
+tests = ["tox"]
+
+[[package]]
+name = "django-q2"
+version = "1.6.2"
+description = "A multiprocessing distributed task queue for Django"
+category = "main"
+optional = false
+python-versions = ">=3.8,<4"
+files = [
+ {file = "django_q2-1.6.2-py3-none-any.whl", hash = "sha256:c2d75552c80b83ca0d8c0b0db7db4f17e9f43ee131a46d0ddd514c5f5fc603cb"},
+ {file = "django_q2-1.6.2.tar.gz", hash = "sha256:cd83c16b5791cd99f83a8d106d2447305d73c6c8ed8ec22c7cb954fe0e814284"},
+]
+
+[package.dependencies]
+django = ">=3.2,<6"
+django-picklefield = ">=3.1,<4.0"
+importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""}
+
+[package.extras]
+rollbar = ["django-q-rollbar (>=0.1)"]
+sentry = ["django-q-sentry (>=0.1)"]
+testing = ["blessed (>=1.19.1,<2.0.0)", "boto3 (>=1.24.92,<2.0.0)", "croniter (>=2.0.1,<3.0.0)", "django-redis (>=5.2.0,<6.0.0)", "hiredis (>=2.0.0,<3.0.0)", "iron-mq (>=0.9,<0.10)", "psutil (>=5.9.2,<6.0.0)", "pymongo (>=4.2.0,<5.0.0)", "redis (>=4.3.4,<5.0.0)", "setproctitle (>=1.3.2,<2.0.0)"]
+
[[package]]
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 = [
@@ -467,6 +528,7 @@ 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 = [
@@ -490,6 +552,7 @@ sftp = ["paramiko"]
name = "djangorestframework"
version = "3.14.0"
description = "Web APIs for Django, made easy."
+category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -505,6 +568,7 @@ pytz = "*"
name = "google-api-core"
version = "1.34.0"
description = "Google API client core library"
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -529,6 +593,7 @@ 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 = [
@@ -548,6 +613,7 @@ 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 = [
@@ -571,6 +637,7 @@ 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 = [
@@ -587,6 +654,7 @@ 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 = [
@@ -606,6 +674,7 @@ 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 = [
@@ -626,6 +695,7 @@ 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 = [
@@ -634,10 +704,10 @@ files = [
]
[package.dependencies]
-google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
+google-api-core = {version = ">=1.34.0,<2.0.0 || >=2.11.0,<3.0.0dev", extras = ["grpc"]}
proto-plus = [
- {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""},
{version = ">=1.22.0,<2.0.0dev", markers = "python_version < \"3.11\""},
+ {version = ">=1.22.2,<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"
@@ -645,6 +715,7 @@ 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 = [
@@ -689,6 +760,7 @@ 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 = [
@@ -708,6 +780,7 @@ 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 = [
@@ -723,65 +796,67 @@ grpc = ["grpcio (>=1.44.0,<2.0.0dev)"]
[[package]]
name = "grpcio"
-version = "1.53.0"
+version = "1.53.2"
description = "HTTP/2-based RPC framework"
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
- {file = "grpcio-1.53.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:752d2949b40e12e6ad3ed8cc552a65b54d226504f6b1fb67cab2ccee502cc06f"},
- {file = "grpcio-1.53.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:8a48fd3a7222be226bb86b7b413ad248f17f3101a524018cdc4562eeae1eb2a3"},
- {file = "grpcio-1.53.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:f3e837d29f0e1b9d6e7b29d569e2e9b0da61889e41879832ea15569c251c303a"},
- {file = "grpcio-1.53.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aef7d30242409c3aa5839b501e877e453a2c8d3759ca8230dd5a21cda029f046"},
- {file = "grpcio-1.53.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6f90698b5d1c5dd7b3236cd1fa959d7b80e17923f918d5be020b65f1c78b173"},
- {file = "grpcio-1.53.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a96c3c7f564b263c5d7c0e49a337166c8611e89c4c919f66dba7b9a84abad137"},
- {file = "grpcio-1.53.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ee81349411648d1abc94095c68cd25e3c2812e4e0367f9a9355be1e804a5135c"},
- {file = "grpcio-1.53.0-cp310-cp310-win32.whl", hash = "sha256:fdc6191587de410a184550d4143e2b24a14df495c86ca15e59508710681690ac"},
- {file = "grpcio-1.53.0-cp310-cp310-win_amd64.whl", hash = "sha256:658ffe1e39171be00490db5bd3b966f79634ac4215a1eb9a85c6cd6783bf7f6e"},
- {file = "grpcio-1.53.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:1b172e6d497191940c4b8d75b53de82dc252e15b61de2951d577ec5b43316b29"},
- {file = "grpcio-1.53.0-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:82434ba3a5935e47908bc861ce1ebc43c2edfc1001d235d6e31e5d3ed55815f7"},
- {file = "grpcio-1.53.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:1c734a2d4843e4e14ececf5600c3c4750990ec319e1299db7e4f0d02c25c1467"},
- {file = "grpcio-1.53.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6a2ead3de3b2d53119d473aa2f224030257ef33af1e4ddabd4afee1dea5f04c"},
- {file = "grpcio-1.53.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a34d6e905f071f9b945cabbcc776e2055de1fdb59cd13683d9aa0a8f265b5bf9"},
- {file = "grpcio-1.53.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eaf8e3b97caaf9415227a3c6ca5aa8d800fecadd526538d2bf8f11af783f1550"},
- {file = "grpcio-1.53.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:da95778d37be8e4e9afca771a83424f892296f5dfb2a100eda2571a1d8bbc0dc"},
- {file = "grpcio-1.53.0-cp311-cp311-win32.whl", hash = "sha256:e4f513d63df6336fd84b74b701f17d1bb3b64e9d78a6ed5b5e8a198bbbe8bbfa"},
- {file = "grpcio-1.53.0-cp311-cp311-win_amd64.whl", hash = "sha256:ddb2511fbbb440ed9e5c9a4b9b870f2ed649b7715859fd6f2ebc585ee85c0364"},
- {file = "grpcio-1.53.0-cp37-cp37m-linux_armv7l.whl", hash = "sha256:2a912397eb8d23c177d6d64e3c8bc46b8a1c7680b090d9f13a640b104aaec77c"},
- {file = "grpcio-1.53.0-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:55930c56b8f5b347d6c8c609cc341949a97e176c90f5cbb01d148d778f3bbd23"},
- {file = "grpcio-1.53.0-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:6601d812105583948ab9c6e403a7e2dba6e387cc678c010e74f2d6d589d1d1b3"},
- {file = "grpcio-1.53.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c705e0c21acb0e8478a00e7e773ad0ecdb34bd0e4adc282d3d2f51ba3961aac7"},
- {file = "grpcio-1.53.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba074af9ca268ad7b05d3fc2b920b5fb3c083da94ab63637aaf67f4f71ecb755"},
- {file = "grpcio-1.53.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:14817de09317dd7d3fbc8272864288320739973ef0f4b56bf2c0032349da8cdf"},
- {file = "grpcio-1.53.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c7ad9fbedb93f331c2e9054e202e95cf825b885811f1bcbbdfdc301e451442db"},
- {file = "grpcio-1.53.0-cp37-cp37m-win_amd64.whl", hash = "sha256:dad5b302a4c21c604d88a5d441973f320134e6ff6a84ecef9c1139e5ffd466f6"},
- {file = "grpcio-1.53.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:fa8eaac75d3107e3f5465f2c9e3bbd13db21790c6e45b7de1756eba16b050aca"},
- {file = "grpcio-1.53.0-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:104a2210edd3776c38448b4f76c2f16e527adafbde171fc72a8a32976c20abc7"},
- {file = "grpcio-1.53.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:dbc1ba968639c1d23476f75c356e549e7bbf2d8d6688717dcab5290e88e8482b"},
- {file = "grpcio-1.53.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95952d3fe795b06af29bb8ec7bbf3342cdd867fc17b77cc25e6733d23fa6c519"},
- {file = "grpcio-1.53.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f144a790f14c51b8a8e591eb5af40507ffee45ea6b818c2482f0457fec2e1a2e"},
- {file = "grpcio-1.53.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0698c094688a2dd4c7c2f2c0e3e142cac439a64d1cef6904c97f6cde38ba422f"},
- {file = "grpcio-1.53.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6b6d60b0958be711bab047e9f4df5dbbc40367955f8651232bfdcdd21450b9ab"},
- {file = "grpcio-1.53.0-cp38-cp38-win32.whl", hash = "sha256:1948539ce78805d4e6256ab0e048ec793956d54787dc9d6777df71c1d19c7f81"},
- {file = "grpcio-1.53.0-cp38-cp38-win_amd64.whl", hash = "sha256:df9ba1183b3f649210788cf80c239041dddcb375d6142d8bccafcfdf549522cd"},
- {file = "grpcio-1.53.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:19caa5b7282a89b799e63776ff602bb39604f7ca98db6df27e2de06756ae86c3"},
- {file = "grpcio-1.53.0-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:b5bd026ac928c96cc23149e6ef79183125542062eb6d1ccec34c0a37e02255e7"},
- {file = "grpcio-1.53.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:7dc8584ca6c015ad82e186e82f4c0fe977394588f66b8ecfc4ec873285314619"},
- {file = "grpcio-1.53.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2eddaae8af625e45b5c8500dcca1043264d751a6872cde2eda5022df8a336959"},
- {file = "grpcio-1.53.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5fb6f3d7824696c1c9f2ad36ddb080ba5a86f2d929ef712d511b4d9972d3d27"},
- {file = "grpcio-1.53.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8270d1dc2c98ab57e6dbf36fa187db8df4c036f04a398e5d5e25b4e01a766d70"},
- {file = "grpcio-1.53.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:976a7f24eb213e8429cab78d5e120500dfcdeb01041f1f5a77b17b9101902615"},
- {file = "grpcio-1.53.0-cp39-cp39-win32.whl", hash = "sha256:9c84a481451e7174f3a764a44150f93b041ab51045aa33d7b5b68b6979114e48"},
- {file = "grpcio-1.53.0-cp39-cp39-win_amd64.whl", hash = "sha256:6beb84f83360ff29a3654f43f251ec11b809dcb5524b698d711550243debd289"},
- {file = "grpcio-1.53.0.tar.gz", hash = "sha256:a4952899b4931a6ba12951f9a141ef3e74ff8a6ec9aa2dc602afa40f63595e33"},
+ {file = "grpcio-1.53.2-cp310-cp310-linux_armv7l.whl", hash = "sha256:18afdda2bbe0c615da4daff754cab0df9bbd859c415d85e7e741a2975b3208b4"},
+ {file = "grpcio-1.53.2-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:33f7678287ac330c94e25f96cdb951e0861e206115ba4d8ea66cf6546b1a09d0"},
+ {file = "grpcio-1.53.2-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:1af074f28a56425e4f4d99761708981543a27ae963f5b4b0a36ff71f3483479d"},
+ {file = "grpcio-1.53.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df07843c8c0dc71a56d3af3dfe19165fb0d3af7d3354a72185f6fa1b4ac05cab"},
+ {file = "grpcio-1.53.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b403c4ad22f3ba37c7720547d8888a1e4b74ad980a94332bbbc50330b623abc"},
+ {file = "grpcio-1.53.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6275a54b41d6b1ec539b019bc3affaf6d05b0a0ba36af1a65b8a2810ef69e07d"},
+ {file = "grpcio-1.53.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0e92dc6a85cd1de42527812ef1276095e62169d002d86c888b6e889fcda1dd29"},
+ {file = "grpcio-1.53.2-cp310-cp310-win32.whl", hash = "sha256:6be86e8d5cf47415968588e5dfbfb92ee8757fb41139584192b67050d1a72c58"},
+ {file = "grpcio-1.53.2-cp310-cp310-win_amd64.whl", hash = "sha256:5b49f372df33f5f84865aef5d46cacd23180b586c80e8cbe0ce149b96dfa8c4c"},
+ {file = "grpcio-1.53.2-cp311-cp311-linux_armv7l.whl", hash = "sha256:8166ac6671472d172cc0db50323b7a7504bd534de54aa31354465a00ca44409d"},
+ {file = "grpcio-1.53.2-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:f7e66d8b31ef2bada7029275debbe12c97397ec7ac70a659837a7b8a6a9dc916"},
+ {file = "grpcio-1.53.2-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:712113946b303db9ae4245a13de213710367850a6c3c53530b70e87989feb8e0"},
+ {file = "grpcio-1.53.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3761f9a6817e32898eaa5aecd0b0ad69d0c68ab45ea7bf206e8dc4548f025f0"},
+ {file = "grpcio-1.53.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24c63592103fded38b258f1e520ba8b0a7a0bbc397cddd6520a1f74dc4b5dec0"},
+ {file = "grpcio-1.53.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:53d34cbf212f03634d74ba366d595b4a06a3b60fcc731eddbd6fd7ebe4acf981"},
+ {file = "grpcio-1.53.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3b789472e9ef75d179295d0c6a1f7f0aefd08189cd1c822b068b0523365a1dbe"},
+ {file = "grpcio-1.53.2-cp311-cp311-win32.whl", hash = "sha256:7b44ed75b9d67d17e5a098a0f99a8fd3e5861fd3c4eb54212277a0acdf298434"},
+ {file = "grpcio-1.53.2-cp311-cp311-win_amd64.whl", hash = "sha256:1d1a320230e0d020880178b8eb453300bd57700b44c3744268370502e7376a9b"},
+ {file = "grpcio-1.53.2-cp37-cp37m-linux_armv7l.whl", hash = "sha256:69e99fe6bdc2cdacd04cef6b6585b00630d958c98e36d825de3eea406e15fb31"},
+ {file = "grpcio-1.53.2-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:590c7206f764cfe37a65003a75977358e20919ed488f970935f54efa2741b497"},
+ {file = "grpcio-1.53.2-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:1fcced1abb13cdb6a5d8b105765d30212a6cb29ab0dfb01eedecf2ff6c84371b"},
+ {file = "grpcio-1.53.2-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07b83c06e7d113044cf3da15ca52f578c5f3dca299af711e9a589c1b71eb8be5"},
+ {file = "grpcio-1.53.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3bee217bda6b2c81d9e2866f523217135a03a007a89043eee074e93d76706b0"},
+ {file = "grpcio-1.53.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9efbedc737ba342d8a2459afc9bd5c5df31adcdf774b772a4e663739f2cf0d06"},
+ {file = "grpcio-1.53.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0f76287d98ec79a38cba8292d0bdcd6ab9b9daf568dce1d53b9eb0135fc14d26"},
+ {file = "grpcio-1.53.2-cp37-cp37m-win_amd64.whl", hash = "sha256:d406cf2f6ccf39883a24b048c448a37bac16939408c1b6fbb4d021f3cd961448"},
+ {file = "grpcio-1.53.2-cp38-cp38-linux_armv7l.whl", hash = "sha256:f9f7c0dd17f24e1774cc3a8df738246772994e853c28b28ed6ba7711ccf0abb4"},
+ {file = "grpcio-1.53.2-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:f14a82d12d53eb93298c35edf88d8c3ef37243b95f94dd3c75fddcba575d34ab"},
+ {file = "grpcio-1.53.2-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:1deeb84bb344351434f999cea4704ac6f1e07b3d861e34c44b50d8afa06caaa1"},
+ {file = "grpcio-1.53.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cbf1e3aaec3edf734ef90182363a395d234cd4790544be914cedbe1b9fec99a"},
+ {file = "grpcio-1.53.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ea235cecb9df14b49a75cbd27a634683a96bb76576363407ec820ae454ce2b2"},
+ {file = "grpcio-1.53.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1df931fbb4c36363d2cb985c2c26fda8f060b541a89c6c1191fdb59151a8c934"},
+ {file = "grpcio-1.53.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7734d1b91f1f3b1f186debf8ec4d168ee088a54e8186c14d89a95f7e51d3198d"},
+ {file = "grpcio-1.53.2-cp38-cp38-win32.whl", hash = "sha256:b16258a31269b97e26a08d71b5deb56499e86077d26e453fad8f6ec4c06fe666"},
+ {file = "grpcio-1.53.2-cp38-cp38-win_amd64.whl", hash = "sha256:b676c4365a5753bc8c49f922a5f88bdb5df6746c670a9d859d2ba2f5f97d9269"},
+ {file = "grpcio-1.53.2-cp39-cp39-linux_armv7l.whl", hash = "sha256:8fc7667564c8c15748354dea1bb4035c5118df4e9dc5154ccdb6e62a3e5a2bac"},
+ {file = "grpcio-1.53.2-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:80a8867746cff41c2db436dd9eea18ebbfcd0449d65b64b3ed3c995207898971"},
+ {file = "grpcio-1.53.2-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:431f864f2642a97d0aa8c6b606c307f03d22f919b1a226af90488426aed35809"},
+ {file = "grpcio-1.53.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bea6a20c5a732a27b64623d43614b3022e6fcfc081a75236b7f9aa069d2eaa4d"},
+ {file = "grpcio-1.53.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504af9e86ab01c9c33d8a452fe846aa931d024945f2e897537ccb8f7d76778ee"},
+ {file = "grpcio-1.53.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2b4f5671f9e88b7f51f54adda37a23277b7fdebd1557c47543b3e8a8044dd510"},
+ {file = "grpcio-1.53.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d9c51ca201326b49cfee38336c6e7dd1cb8a6b6d0dcf84aeaecbae310a736dbc"},
+ {file = "grpcio-1.53.2-cp39-cp39-win32.whl", hash = "sha256:7e6885a8431939f1ee547e965fa3cb801a518b83d3d3509e90dbef78f0b5fd29"},
+ {file = "grpcio-1.53.2-cp39-cp39-win_amd64.whl", hash = "sha256:ea84becb5cbd6a94a810c5214eb263ae57e915a9ed1bdcd5b4a6baf13d8c5177"},
+ {file = "grpcio-1.53.2.tar.gz", hash = "sha256:0c9e42f2499c8603af1d88771dc97e2c6b0310c278337058fd7fd1ddb35ab853"},
]
[package.extras]
-protobuf = ["grpcio-tools (>=1.53.0)"]
+protobuf = ["grpcio-tools (>=1.53.2)"]
[[package]]
name = "grpcio-status"
version = "1.48.2"
description = "Status proto mapping for gRPC"
+category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -798,6 +873,7 @@ 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 = [
@@ -813,6 +889,7 @@ 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 = [
@@ -824,6 +901,7 @@ files = [
name = "httplib2"
version = "0.19.1"
description = "A comprehensive HTTP client library."
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -838,6 +916,7 @@ 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 = [
@@ -845,10 +924,49 @@ files = [
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
]
+[[package]]
+name = "importlib-metadata"
+version = "7.0.2"
+description = "Read metadata from Python packages"
+category = "main"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "importlib_metadata-7.0.2-py3-none-any.whl", hash = "sha256:f4bc4c0c070c490abf4ce96d715f68e95923320370efb66143df00199bb6c100"},
+ {file = "importlib_metadata-7.0.2.tar.gz", hash = "sha256:198f568f3230878cb1b44fbd7975f87906c22336dba2e4a7f05278c281fbd792"},
+]
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+perf = ["ipython"]
+testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"]
+
+[[package]]
+name = "jinja2"
+version = "3.1.3"
+description = "A very fast and expressive template engine."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"},
+ {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
[[package]]
name = "jmespath"
version = "1.0.1"
description = "JSON Matching Expressions"
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -858,22 +976,25 @@ files = [
[[package]]
name = "jwcrypto"
-version = "1.4.2"
+version = "1.5.6"
description = "Implementation of JOSE Web standards"
+category = "main"
optional = false
-python-versions = ">= 3.6"
+python-versions = ">= 3.8"
files = [
- {file = "jwcrypto-1.4.2.tar.gz", hash = "sha256:80a35e9ed1b3b2c43ce03d92c5d48e6d0b6647e2aa2618e4963448923d78a37b"},
+ {file = "jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789"},
+ {file = "jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039"},
]
[package.dependencies]
-cryptography = ">=2.3"
-deprecated = "*"
+cryptography = ">=3.4"
+typing-extensions = ">=4.5.0"
[[package]]
name = "libsass"
version = "0.21.0"
description = "Sass for Python: A straightforward binding of libsass for Python."
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -892,10 +1013,131 @@ files = [
[package.dependencies]
six = "*"
+[[package]]
+name = "markupsafe"
+version = "2.1.3"
+description = "Safely add untrusted strings to HTML/XML markup."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"},
+ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"},
+]
+
+[[package]]
+name = "moto"
+version = "4.2.10"
+description = ""
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "moto-4.2.10-py2.py3-none-any.whl", hash = "sha256:5cf0736d1f43cb887498d00b00ae522774bfddb7db1f4994fedea65b290b9f0e"},
+ {file = "moto-4.2.10.tar.gz", hash = "sha256:92595fe287474a31ac3ef847941ebb097e8ffb0c3d6c106e47cf573db06933b2"},
+]
+
+[package.dependencies]
+boto3 = ">=1.9.201"
+botocore = ">=1.12.201"
+cryptography = ">=3.3.1"
+Jinja2 = ">=2.10.1"
+python-dateutil = ">=2.1,<3.0.0"
+requests = ">=2.5"
+responses = ">=0.13.0"
+werkzeug = ">=0.5,<2.2.0 || >2.2.0,<2.2.1 || >2.2.1"
+xmltodict = "*"
+
+[package.extras]
+all = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "multipart", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.4.2)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"]
+apigateway = ["PyYAML (>=5.1)", "ecdsa (!=0.15)", "openapi-spec-validator (>=0.5.0)", "python-jose[cryptography] (>=3.1.0,<4.0.0)"]
+apigatewayv2 = ["PyYAML (>=5.1)"]
+appsync = ["graphql-core"]
+awslambda = ["docker (>=3.0.0)"]
+batch = ["docker (>=3.0.0)"]
+cloudformation = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.4.2)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"]
+cognitoidp = ["ecdsa (!=0.15)", "python-jose[cryptography] (>=3.1.0,<4.0.0)"]
+ds = ["sshpubkeys (>=3.1.0)"]
+dynamodb = ["docker (>=3.0.0)", "py-partiql-parser (==0.4.2)"]
+dynamodbstreams = ["docker (>=3.0.0)", "py-partiql-parser (==0.4.2)"]
+ebs = ["sshpubkeys (>=3.1.0)"]
+ec2 = ["sshpubkeys (>=3.1.0)"]
+efs = ["sshpubkeys (>=3.1.0)"]
+eks = ["sshpubkeys (>=3.1.0)"]
+glue = ["pyparsing (>=3.0.7)"]
+iotdata = ["jsondiff (>=1.1.2)"]
+proxy = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=2.5.1)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "multipart", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.4.2)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"]
+resourcegroupstaggingapi = ["PyYAML (>=5.1)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.4.2)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "sshpubkeys (>=3.1.0)"]
+route53resolver = ["sshpubkeys (>=3.1.0)"]
+s3 = ["PyYAML (>=5.1)", "py-partiql-parser (==0.4.2)"]
+s3crc32c = ["PyYAML (>=5.1)", "crc32c", "py-partiql-parser (==0.4.2)"]
+server = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "flask (!=2.2.0,!=2.2.1)", "flask-cors", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.4.2)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"]
+ssm = ["PyYAML (>=5.1)"]
+xray = ["aws-xray-sdk (>=0.93,!=0.96)", "setuptools"]
+
[[package]]
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 = [
@@ -912,6 +1154,7 @@ 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 = [
@@ -926,6 +1169,7 @@ pyparsing = ">=2.0.2"
name = "pdfminer.six"
version = "20211012"
description = "PDF parser and analyzer"
+category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -943,75 +1187,95 @@ docs = ["sphinx", "sphinx-argparse"]
[[package]]
name = "pillow"
-version = "10.0.1"
+version = "10.2.0"
description = "Python Imaging Library (Fork)"
+category = "main"
optional = false
python-versions = ">=3.8"
files = [
- {file = "Pillow-10.0.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:8f06be50669087250f319b706decf69ca71fdecd829091a37cc89398ca4dc17a"},
- {file = "Pillow-10.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:50bd5f1ebafe9362ad622072a1d2f5850ecfa44303531ff14353a4059113b12d"},
- {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6a90167bcca1216606223a05e2cf991bb25b14695c518bc65639463d7db722d"},
- {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f11c9102c56ffb9ca87134bd025a43d2aba3f1155f508eff88f694b33a9c6d19"},
- {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:186f7e04248103482ea6354af6d5bcedb62941ee08f7f788a1c7707bc720c66f"},
- {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0462b1496505a3462d0f35dc1c4d7b54069747d65d00ef48e736acda2c8cbdff"},
- {file = "Pillow-10.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d889b53ae2f030f756e61a7bff13684dcd77e9af8b10c6048fb2c559d6ed6eaf"},
- {file = "Pillow-10.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:552912dbca585b74d75279a7570dd29fa43b6d93594abb494ebb31ac19ace6bd"},
- {file = "Pillow-10.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:787bb0169d2385a798888e1122c980c6eff26bf941a8ea79747d35d8f9210ca0"},
- {file = "Pillow-10.0.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fd2a5403a75b54661182b75ec6132437a181209b901446ee5724b589af8edef1"},
- {file = "Pillow-10.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2d7e91b4379f7a76b31c2dda84ab9e20c6220488e50f7822e59dac36b0cd92b1"},
- {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19e9adb3f22d4c416e7cd79b01375b17159d6990003633ff1d8377e21b7f1b21"},
- {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93139acd8109edcdeffd85e3af8ae7d88b258b3a1e13a038f542b79b6d255c54"},
- {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:92a23b0431941a33242b1f0ce6c88a952e09feeea9af4e8be48236a68ffe2205"},
- {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cbe68deb8580462ca0d9eb56a81912f59eb4542e1ef8f987405e35a0179f4ea2"},
- {file = "Pillow-10.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:522ff4ac3aaf839242c6f4e5b406634bfea002469656ae8358644fc6c4856a3b"},
- {file = "Pillow-10.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:84efb46e8d881bb06b35d1d541aa87f574b58e87f781cbba8d200daa835b42e1"},
- {file = "Pillow-10.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:898f1d306298ff40dc1b9ca24824f0488f6f039bc0e25cfb549d3195ffa17088"},
- {file = "Pillow-10.0.1-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:bcf1207e2f2385a576832af02702de104be71301c2696d0012b1b93fe34aaa5b"},
- {file = "Pillow-10.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d6c9049c6274c1bb565021367431ad04481ebb54872edecfcd6088d27edd6ed"},
- {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28444cb6ad49726127d6b340217f0627abc8732f1194fd5352dec5e6a0105635"},
- {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de596695a75496deb3b499c8c4f8e60376e0516e1a774e7bc046f0f48cd620ad"},
- {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2872f2d7846cf39b3dbff64bc1104cc48c76145854256451d33c5faa55c04d1a"},
- {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ce90f8a24e1c15465048959f1e94309dfef93af272633e8f37361b824532e91"},
- {file = "Pillow-10.0.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ee7810cf7c83fa227ba9125de6084e5e8b08c59038a7b2c9045ef4dde61663b4"},
- {file = "Pillow-10.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b1be1c872b9b5fcc229adeadbeb51422a9633abd847c0ff87dc4ef9bb184ae08"},
- {file = "Pillow-10.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:98533fd7fa764e5f85eebe56c8e4094db912ccbe6fbf3a58778d543cadd0db08"},
- {file = "Pillow-10.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:764d2c0daf9c4d40ad12fbc0abd5da3af7f8aa11daf87e4fa1b834000f4b6b0a"},
- {file = "Pillow-10.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fcb59711009b0168d6ee0bd8fb5eb259c4ab1717b2f538bbf36bacf207ef7a68"},
- {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:697a06bdcedd473b35e50a7e7506b1d8ceb832dc238a336bd6f4f5aa91a4b500"},
- {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f665d1e6474af9f9da5e86c2a3a2d2d6204e04d5af9c06b9d42afa6ebde3f21"},
- {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:2fa6dd2661838c66f1a5473f3b49ab610c98a128fc08afbe81b91a1f0bf8c51d"},
- {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:3a04359f308ebee571a3127fdb1bd01f88ba6f6fb6d087f8dd2e0d9bff43f2a7"},
- {file = "Pillow-10.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:723bd25051454cea9990203405fa6b74e043ea76d4968166dfd2569b0210886a"},
- {file = "Pillow-10.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:71671503e3015da1b50bd18951e2f9daf5b6ffe36d16f1eb2c45711a301521a7"},
- {file = "Pillow-10.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:44e7e4587392953e5e251190a964675f61e4dae88d1e6edbe9f36d6243547ff3"},
- {file = "Pillow-10.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:3855447d98cced8670aaa63683808df905e956f00348732448b5a6df67ee5849"},
- {file = "Pillow-10.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ed2d9c0704f2dc4fa980b99d565c0c9a543fe5101c25b3d60488b8ba80f0cce1"},
- {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5bb289bb835f9fe1a1e9300d011eef4d69661bb9b34d5e196e5e82c4cb09b37"},
- {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0d3e54ab1df9df51b914b2233cf779a5a10dfd1ce339d0421748232cea9876"},
- {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:2cc6b86ece42a11f16f55fe8903595eff2b25e0358dec635d0a701ac9586588f"},
- {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:ca26ba5767888c84bf5a0c1a32f069e8204ce8c21d00a49c90dabeba00ce0145"},
- {file = "Pillow-10.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f0b4b06da13275bc02adfeb82643c4a6385bd08d26f03068c2796f60d125f6f2"},
- {file = "Pillow-10.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bc2e3069569ea9dbe88d6b8ea38f439a6aad8f6e7a6283a38edf61ddefb3a9bf"},
- {file = "Pillow-10.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:8b451d6ead6e3500b6ce5c7916a43d8d8d25ad74b9102a629baccc0808c54971"},
- {file = "Pillow-10.0.1-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:32bec7423cdf25c9038fef614a853c9d25c07590e1a870ed471f47fb80b244db"},
- {file = "Pillow-10.0.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cf63d2c6928b51d35dfdbda6f2c1fddbe51a6bc4a9d4ee6ea0e11670dd981e"},
- {file = "Pillow-10.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f6d3d4c905e26354e8f9d82548475c46d8e0889538cb0657aa9c6f0872a37aa4"},
- {file = "Pillow-10.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:847e8d1017c741c735d3cd1883fa7b03ded4f825a6e5fcb9378fd813edee995f"},
- {file = "Pillow-10.0.1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:7f771e7219ff04b79e231d099c0a28ed83aa82af91fd5fa9fdb28f5b8d5addaf"},
- {file = "Pillow-10.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459307cacdd4138edee3875bbe22a2492519e060660eaf378ba3b405d1c66317"},
- {file = "Pillow-10.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b059ac2c4c7a97daafa7dc850b43b2d3667def858a4f112d1aa082e5c3d6cf7d"},
- {file = "Pillow-10.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6caf3cd38449ec3cd8a68b375e0c6fe4b6fd04edb6c9766b55ef84a6e8ddf2d"},
- {file = "Pillow-10.0.1.tar.gz", hash = "sha256:d72967b06be9300fed5cfbc8b5bafceec48bf7cdc7dab66b1d2549035287191d"},
+ {file = "pillow-10.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e"},
+ {file = "pillow-10.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588"},
+ {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452"},
+ {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4"},
+ {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563"},
+ {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2"},
+ {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c"},
+ {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0"},
+ {file = "pillow-10.2.0-cp310-cp310-win32.whl", hash = "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023"},
+ {file = "pillow-10.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72"},
+ {file = "pillow-10.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad"},
+ {file = "pillow-10.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5"},
+ {file = "pillow-10.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67"},
+ {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61"},
+ {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e"},
+ {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f"},
+ {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311"},
+ {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1"},
+ {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757"},
+ {file = "pillow-10.2.0-cp311-cp311-win32.whl", hash = "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068"},
+ {file = "pillow-10.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56"},
+ {file = "pillow-10.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1"},
+ {file = "pillow-10.2.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef"},
+ {file = "pillow-10.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac"},
+ {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c"},
+ {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa"},
+ {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2"},
+ {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04"},
+ {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f"},
+ {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb"},
+ {file = "pillow-10.2.0-cp312-cp312-win32.whl", hash = "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f"},
+ {file = "pillow-10.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9"},
+ {file = "pillow-10.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48"},
+ {file = "pillow-10.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9"},
+ {file = "pillow-10.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483"},
+ {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129"},
+ {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e"},
+ {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213"},
+ {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d"},
+ {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6"},
+ {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe"},
+ {file = "pillow-10.2.0-cp38-cp38-win32.whl", hash = "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e"},
+ {file = "pillow-10.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39"},
+ {file = "pillow-10.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67"},
+ {file = "pillow-10.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364"},
+ {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb"},
+ {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e"},
+ {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01"},
+ {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13"},
+ {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7"},
+ {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591"},
+ {file = "pillow-10.2.0-cp39-cp39-win32.whl", hash = "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516"},
+ {file = "pillow-10.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8"},
+ {file = "pillow-10.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6"},
+ {file = "pillow-10.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868"},
+ {file = "pillow-10.2.0.tar.gz", hash = "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"]
+fpx = ["olefile"]
+mic = ["olefile"]
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
+typing = ["typing-extensions"]
+xmp = ["defusedxml"]
[[package]]
name = "proto-plus"
version = "1.22.2"
description = "Beautiful, Pythonic protocol buffers."
+category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -1029,9 +1293,11 @@ 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"},
@@ -1060,6 +1326,7 @@ files = [
name = "psycopg2"
version = "2.9.5"
description = "psycopg2 - Python-PostgreSQL Database Adapter"
+category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -1082,10 +1349,22 @@ 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"},
]
@@ -1093,11 +1372,23 @@ 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]
@@ -1107,6 +1398,7 @@ 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 = [
@@ -1118,6 +1410,7 @@ files = [
name = "pyopenssl"
version = "23.2.0"
description = "Python wrapper module around the OpenSSL library"
+category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -1136,6 +1429,7 @@ 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 = [
@@ -1147,6 +1441,7 @@ 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 = [
@@ -1161,6 +1456,7 @@ six = ">=1.5"
name = "python-decouple"
version = "3.4"
description = "Strict separation of settings from code."
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -1172,6 +1468,7 @@ 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 = [
@@ -1183,6 +1480,7 @@ files = [
name = "pytz"
version = "2022.1"
description = "World timezone definitions, modern and historical"
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -1190,10 +1488,72 @@ files = [
{file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"},
]
+[[package]]
+name = "pyyaml"
+version = "6.0.1"
+description = "YAML parser and emitter for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+ {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+ {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+ {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
+ {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+ {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
+ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
+]
+
[[package]]
name = "requests"
version = "2.31.0"
description = "Python HTTP for Humans."
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1215,6 +1575,7 @@ 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 = [
@@ -1234,11 +1595,13 @@ 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]
@@ -1248,10 +1611,31 @@ requests = ">=2.0.0"
[package.extras]
rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
+[[package]]
+name = "responses"
+version = "0.24.1"
+description = "A utility library for mocking out the `requests` Python library."
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "responses-0.24.1-py3-none-any.whl", hash = "sha256:a2b43f4c08bfb9c9bd242568328c65a34b318741d3fab884ac843c5ceeb543f9"},
+ {file = "responses-0.24.1.tar.gz", hash = "sha256:b127c6ca3f8df0eb9cc82fd93109a3007a86acb24871834c47b77765152ecf8c"},
+]
+
+[package.dependencies]
+pyyaml = "*"
+requests = ">=2.30.0,<3.0"
+urllib3 = ">=1.25.10,<3.0"
+
+[package.extras]
+tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"]
+
[[package]]
name = "rsa"
version = "4.7.2"
description = "Pure-Python RSA implementation"
+category = "main"
optional = false
python-versions = ">=3.5, <4"
files = [
@@ -1266,6 +1650,7 @@ 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 = [
@@ -1283,6 +1668,7 @@ 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 = [
@@ -1297,6 +1683,7 @@ urllib3 = "*"
name = "sentry-sdk"
version = "1.14.0"
description = "Python client for Sentry (https://sentry.io)"
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -1335,6 +1722,7 @@ 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 = [
@@ -1351,6 +1739,7 @@ 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 = [
@@ -1362,6 +1751,7 @@ files = [
name = "sqlparse"
version = "0.4.4"
description = "A non-validating SQL parser."
+category = "main"
optional = false
python-versions = ">=3.5"
files = [
@@ -1374,10 +1764,23 @@ dev = ["build", "flake8"]
doc = ["sphinx"]
test = ["pytest", "pytest-cov"]
+[[package]]
+name = "typing-extensions"
+version = "4.10.0"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+category = "main"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"},
+ {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"},
+]
+
[[package]]
name = "tzdata"
version = "2022.7"
description = "Provider of IANA time zone data"
+category = "main"
optional = false
python-versions = ">=2"
files = [
@@ -1389,6 +1792,7 @@ 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 = [
@@ -1400,6 +1804,7 @@ 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 = [
@@ -1416,6 +1821,7 @@ 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 = [
@@ -1426,6 +1832,7 @@ files = [
name = "webencodings"
version = "0.5.1"
description = "Character encoding aliases for legacy web content"
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -1434,93 +1841,56 @@ files = [
]
[[package]]
-name = "wrapt"
-version = "1.15.0"
-description = "Module for decorators, wrappers and monkey patching."
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
-files = [
- {file = "wrapt-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1"},
- {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29"},
- {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2"},
- {file = "wrapt-1.15.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46"},
- {file = "wrapt-1.15.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c"},
- {file = "wrapt-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09"},
- {file = "wrapt-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079"},
- {file = "wrapt-1.15.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e"},
- {file = "wrapt-1.15.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a"},
- {file = "wrapt-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923"},
- {file = "wrapt-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee"},
- {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727"},
- {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7"},
- {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0"},
- {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec"},
- {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90"},
- {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975"},
- {file = "wrapt-1.15.0-cp310-cp310-win32.whl", hash = "sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1"},
- {file = "wrapt-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e"},
- {file = "wrapt-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7"},
- {file = "wrapt-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72"},
- {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb"},
- {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e"},
- {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c"},
- {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3"},
- {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92"},
- {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98"},
- {file = "wrapt-1.15.0-cp311-cp311-win32.whl", hash = "sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416"},
- {file = "wrapt-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705"},
- {file = "wrapt-1.15.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29"},
- {file = "wrapt-1.15.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd"},
- {file = "wrapt-1.15.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb"},
- {file = "wrapt-1.15.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248"},
- {file = "wrapt-1.15.0-cp35-cp35m-win32.whl", hash = "sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559"},
- {file = "wrapt-1.15.0-cp35-cp35m-win_amd64.whl", hash = "sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639"},
- {file = "wrapt-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba"},
- {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752"},
- {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364"},
- {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475"},
- {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8"},
- {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418"},
- {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2"},
- {file = "wrapt-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1"},
- {file = "wrapt-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420"},
- {file = "wrapt-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317"},
- {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e"},
- {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e"},
- {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0"},
- {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019"},
- {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034"},
- {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653"},
- {file = "wrapt-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0"},
- {file = "wrapt-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e"},
- {file = "wrapt-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145"},
- {file = "wrapt-1.15.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f"},
- {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd"},
- {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b"},
- {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f"},
- {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6"},
- {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094"},
- {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7"},
- {file = "wrapt-1.15.0-cp38-cp38-win32.whl", hash = "sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b"},
- {file = "wrapt-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1"},
- {file = "wrapt-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86"},
- {file = "wrapt-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c"},
- {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d"},
- {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc"},
- {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29"},
- {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a"},
- {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8"},
- {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9"},
- {file = "wrapt-1.15.0-cp39-cp39-win32.whl", hash = "sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff"},
- {file = "wrapt-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6"},
- {file = "wrapt-1.15.0-py3-none-any.whl", hash = "sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640"},
- {file = "wrapt-1.15.0.tar.gz", hash = "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a"},
+name = "werkzeug"
+version = "3.0.1"
+description = "The comprehensive WSGI web application library."
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10"},
+ {file = "werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc"},
]
+[package.dependencies]
+MarkupSafe = ">=2.1.1"
+
+[package.extras]
+watchdog = ["watchdog (>=2.3)"]
+
+[[package]]
+name = "xmltodict"
+version = "0.13.0"
+description = "Makes working with XML feel like you are working with JSON"
+category = "dev"
+optional = false
+python-versions = ">=3.4"
+files = [
+ {file = "xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852"},
+ {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"},
+]
+
+[[package]]
+name = "zipp"
+version = "3.18.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "zipp-3.18.0-py3-none-any.whl", hash = "sha256:c1bb803ed69d2cce2373152797064f7e79bc43f0a3748eb494096a867e0ebf79"},
+ {file = "zipp-3.18.0.tar.gz", hash = "sha256:df8d042b02765029a09b157efd8e820451045890acc30f8e37dd2f94a060221f"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
+
[[package]]
name = "zxcvbn"
version = "4.4.28"
description = ""
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -1530,4 +1900,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
-content-hash = "e61f939f4000f94213fc7bfb6be961299d04d920f94172670df1900868e0d09d"
+content-hash = "c53c15112493cc7e6249996abd6a5927484e2b6ff31567cb3a28c7c5a747476e"
diff --git a/pyproject.toml b/pyproject.toml
index 8caf5ce5e6..f2e7c48866 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,15 +10,16 @@ bleach = "^3.3.0"
boto3 = "^1.28.53"
botocore = "^1.31.53"
chardet = "^3.0.4"
-cryptography = "^41.0.4"
-Django = "4.1.10"
+cryptography = "^41.0.6"
+Django = "4.2.11"
django-autocomplete-light = "^3.9.4"
django-background-tasks-updated = "=1.2.7"
django-ckeditor = "6.5.1"
djangorestframework = "3.14.0"
+django-q2 = "1.6.2"
google-cloud-storage = "^1.41.1"
html2text = "^2018.1.9"
-Pillow = "^10.0.1"
+Pillow = "^10.2.0"
python-decouple = "^3.1"
uWSGI = "2.0.22"
pyOpenSSL = "^23.2.0"
@@ -46,7 +47,9 @@ urllib3 = "^1.26.18"
[tool.poetry.dev-dependencies]
coverage = "^7.2.3"
+django-coverage-plugin = "^3.1.0"
django-debug-toolbar = "^3.2.4"
+moto = "^4.2.0"
requests = "^2.21.0"
requests-mock = "^1.7.0"
selenium = "^3.141.0"
diff --git a/requirements.txt b/requirements.txt
index 83dbd6ea5b..0cf4491ece 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,6 @@
-asgiref==3.5.2 ; python_version >= "3.9" and python_version < "4.0" \
- --hash=sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4 \
- --hash=sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424
+asgiref==3.7.2 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e \
+ --hash=sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed
bleach==3.3.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:6123ddc1052673e52bab52cdc955bcb57a015264a1c57d37bea2f6b817af0125 \
--hash=sha256:98b3170739e5e83dd9dc19633f074727ad848cbedb6026708c8ac2d3b697a433
@@ -139,33 +139,30 @@ coverage==7.2.3 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:fa546d66639d69aa967bf08156eb8c9d0cd6f6de84be9e8c9819f52ad499c910 \
--hash=sha256:fd214917cabdd6f673a29d708574e9fbdb892cb77eb426d0eae3490d95ca7859 \
--hash=sha256:fff5aaa6becf2c6a1699ae6a39e2e6fb0672c2d42eca8eb0cafa91cf2e9bd312
-cryptography==41.0.4 ; python_version >= "3.9" and python_version < "4.0" \
- --hash=sha256:004b6ccc95943f6a9ad3142cfabcc769d7ee38a3f60fb0dddbfb431f818c3a67 \
- --hash=sha256:047c4603aeb4bbd8db2756e38f5b8bd7e94318c047cfe4efeb5d715e08b49311 \
- --hash=sha256:0d9409894f495d465fe6fda92cb70e8323e9648af912d5b9141d616df40a87b8 \
- --hash=sha256:23a25c09dfd0d9f28da2352503b23e086f8e78096b9fd585d1d14eca01613e13 \
- --hash=sha256:2ed09183922d66c4ec5fdaa59b4d14e105c084dd0febd27452de8f6f74704143 \
- --hash=sha256:35c00f637cd0b9d5b6c6bd11b6c3359194a8eba9c46d4e875a3660e3b400005f \
- --hash=sha256:37480760ae08065437e6573d14be973112c9e6dcaf5f11d00147ee74f37a3829 \
- --hash=sha256:3b224890962a2d7b57cf5eeb16ccaafba6083f7b811829f00476309bce2fe0fd \
- --hash=sha256:5a0f09cefded00e648a127048119f77bc2b2ec61e736660b5789e638f43cc397 \
- --hash=sha256:5b72205a360f3b6176485a333256b9bcd48700fc755fef51c8e7e67c4b63e3ac \
- --hash=sha256:7e53db173370dea832190870e975a1e09c86a879b613948f09eb49324218c14d \
- --hash=sha256:7febc3094125fc126a7f6fb1f420d0da639f3f32cb15c8ff0dc3997c4549f51a \
- --hash=sha256:80907d3faa55dc5434a16579952ac6da800935cd98d14dbd62f6f042c7f5e839 \
- --hash=sha256:86defa8d248c3fa029da68ce61fe735432b047e32179883bdb1e79ed9bb8195e \
- --hash=sha256:8ac4f9ead4bbd0bc8ab2d318f97d85147167a488be0e08814a37eb2f439d5cf6 \
- --hash=sha256:93530900d14c37a46ce3d6c9e6fd35dbe5f5601bf6b3a5c325c7bffc030344d9 \
- --hash=sha256:9eeb77214afae972a00dee47382d2591abe77bdae166bda672fb1e24702a3860 \
- --hash=sha256:b5f4dfe950ff0479f1f00eda09c18798d4f49b98f4e2006d644b3301682ebdca \
- --hash=sha256:c3391bd8e6de35f6f1140e50aaeb3e2b3d6a9012536ca23ab0d9c35ec18c8a91 \
- --hash=sha256:c880eba5175f4307129784eca96f4e70b88e57aa3f680aeba3bab0e980b0f37d \
- --hash=sha256:cecfefa17042941f94ab54f769c8ce0fe14beff2694e9ac684176a2535bf9714 \
- --hash=sha256:e40211b4923ba5a6dc9769eab704bdb3fbb58d56c5b336d30996c24fcf12aadb \
- --hash=sha256:efc8ad4e6fc4f1752ebfb58aefece8b4e3c4cae940b0994d43649bdfce8d0d4f
-deprecated==1.2.13 ; python_version >= "3.9" and python_version < "4.0" \
- --hash=sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d \
- --hash=sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d
+cryptography==41.0.6 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:068bc551698c234742c40049e46840843f3d98ad7ce265fd2bd4ec0d11306596 \
+ --hash=sha256:0f27acb55a4e77b9be8d550d762b0513ef3fc658cd3eb15110ebbcbd626db12c \
+ --hash=sha256:2132d5865eea673fe6712c2ed5fb4fa49dba10768bb4cc798345748380ee3660 \
+ --hash=sha256:3288acccef021e3c3c10d58933f44e8602cf04dba96d9796d70d537bb2f4bbc4 \
+ --hash=sha256:35f3f288e83c3f6f10752467c48919a7a94b7d88cc00b0668372a0d2ad4f8ead \
+ --hash=sha256:398ae1fc711b5eb78e977daa3cbf47cec20f2c08c5da129b7a296055fbb22aed \
+ --hash=sha256:422e3e31d63743855e43e5a6fcc8b4acab860f560f9321b0ee6269cc7ed70cc3 \
+ --hash=sha256:48783b7e2bef51224020efb61b42704207dde583d7e371ef8fc2a5fb6c0aabc7 \
+ --hash=sha256:4d03186af98b1c01a4eda396b137f29e4e3fb0173e30f885e27acec8823c1b09 \
+ --hash=sha256:5daeb18e7886a358064a68dbcaf441c036cbdb7da52ae744e7b9207b04d3908c \
+ --hash=sha256:60e746b11b937911dc70d164060d28d273e31853bb359e2b2033c9e93e6f3c43 \
+ --hash=sha256:742ae5e9a2310e9dade7932f9576606836ed174da3c7d26bc3d3ab4bd49b9f65 \
+ --hash=sha256:7e00fb556bda398b99b0da289ce7053639d33b572847181d6483ad89835115f6 \
+ --hash=sha256:85abd057699b98fce40b41737afb234fef05c67e116f6f3650782c10862c43da \
+ --hash=sha256:8efb2af8d4ba9dbc9c9dd8f04d19a7abb5b49eab1f3694e7b5a16a5fc2856f5c \
+ --hash=sha256:ae236bb8760c1e55b7a39b6d4d32d2279bc6c7c8500b7d5a13b6fb9fc97be35b \
+ --hash=sha256:afda76d84b053923c27ede5edc1ed7d53e3c9f475ebaf63c68e69f1403c405a8 \
+ --hash=sha256:b27a7fd4229abef715e064269d98a7e2909ebf92eb6912a9603c7e14c181928c \
+ --hash=sha256:b648fe2a45e426aaee684ddca2632f62ec4613ef362f4d681a9a6283d10e079d \
+ --hash=sha256:c5a550dc7a3b50b116323e3d376241829fd326ac47bc195e04eb33a8170902a9 \
+ --hash=sha256:da46e2b5df770070412c46f87bac0849b8d685c5f2679771de277a422c7d0b86 \
+ --hash=sha256:f39812f70fc5c71a15aa3c97b2bbe213c3f2a460b79bd21c40d033bb34a9bf36 \
+ --hash=sha256:ff369dd19e8fe0528b02e8df9f2aeb2479f89b1270d90f96a63500afe9af5cae
django-autocomplete-light==3.9.4 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:0f6da75c1c7186698b867a467a8cdb359f0513fdd8e09288a0c2fb018ae3d94e
django-background-tasks-updated==1.2.7 ; python_version >= "3.9" and python_version < "4.0" \
@@ -177,6 +174,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
@@ -186,15 +186,21 @@ django-js-asset==2.0.0 ; python_version >= "3.9" and python_version < "4.0" \
django-oauth-toolkit==2.2.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:46890decb24a34e2a5382debeaf7752e50d90b7a11716cf2a9fd067097ec0963 \
--hash=sha256:abd85c74af525a62365ec2049113e73a2ff8b46ef906e7104a7ba968ef02a11d
+django-picklefield==3.1 ; python_version >= "3.9" and python_version < "4" \
+ --hash=sha256:c786cbeda78d6def2b43bff4840d19787809c8909f7ad683961703060398d356 \
+ --hash=sha256:d77c504df7311e8ec14e8b779f10ca6fec74de6c7f8e2c136e1ef60cf955125d
+django-q2==1.6.2 ; python_version >= "3.9" and python_version < "4" \
+ --hash=sha256:c2d75552c80b83ca0d8c0b0db7db4f17e9f43ee131a46d0ddd514c5f5fc603cb \
+ --hash=sha256:cd83c16b5791cd99f83a8d106d2447305d73c6c8ed8ec22c7cb954fe0e814284
django-sass==1.1.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:992f39d97a7b2d7d891255e5f99512ffd616b2e0aae1ca9b76983c5ecd603342 \
--hash=sha256:da163cd7ad7b9c2f1f10f2ec8b76939640d5b32d21ee74a240ca50a059c5fe7a
django-storages[google]==1.12.3 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:204a99f218b747c46edbfeeb1310d357f83f90fa6a6024d8d0a3f422570cee84 \
--hash=sha256:a475edb2f0f04c4f7e548919a751ecd50117270833956ed5bd585c0575d2a5e7
-django==4.1.10 ; python_version >= "3.9" and python_version < "4.0" \
- --hash=sha256:26d0260c2fb8121009e62ffc548b2398dea2522b6454208a852fb0ef264c206c \
- --hash=sha256:56343019a9fd839e2e5bf203daf45f25af79d5bffa4c71d56eae4f4404d82ade
+django==4.2.11 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:6e6ff3db2d8dd0c986b4eec8554c8e4f919b5c1ff62a5b4390c17aff2ed6e5c4 \
+ --hash=sha256:ddc24a0a8280a0430baa37aff11f28574720af05888c62b7cfe71d219f4599d3
djangorestframework==3.14.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8 \
--hash=sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08
@@ -261,52 +267,52 @@ googleapis-common-protos==1.58.0 ; python_version >= "3.9" and python_version <
grpcio-status==1.48.2 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:2c33bbdbe20188b2953f46f31af669263b6ee2a9b2d38fa0d36ee091532e21bf \
--hash=sha256:53695f45da07437b7c344ee4ef60d370fd2850179f5a28bb26d8e2aa1102ec11
-grpcio==1.53.0 ; python_version >= "3.9" and python_version < "4.0" \
- --hash=sha256:0698c094688a2dd4c7c2f2c0e3e142cac439a64d1cef6904c97f6cde38ba422f \
- --hash=sha256:104a2210edd3776c38448b4f76c2f16e527adafbde171fc72a8a32976c20abc7 \
- --hash=sha256:14817de09317dd7d3fbc8272864288320739973ef0f4b56bf2c0032349da8cdf \
- --hash=sha256:1948539ce78805d4e6256ab0e048ec793956d54787dc9d6777df71c1d19c7f81 \
- --hash=sha256:19caa5b7282a89b799e63776ff602bb39604f7ca98db6df27e2de06756ae86c3 \
- --hash=sha256:1b172e6d497191940c4b8d75b53de82dc252e15b61de2951d577ec5b43316b29 \
- --hash=sha256:1c734a2d4843e4e14ececf5600c3c4750990ec319e1299db7e4f0d02c25c1467 \
- --hash=sha256:2a912397eb8d23c177d6d64e3c8bc46b8a1c7680b090d9f13a640b104aaec77c \
- --hash=sha256:2eddaae8af625e45b5c8500dcca1043264d751a6872cde2eda5022df8a336959 \
- --hash=sha256:55930c56b8f5b347d6c8c609cc341949a97e176c90f5cbb01d148d778f3bbd23 \
- --hash=sha256:658ffe1e39171be00490db5bd3b966f79634ac4215a1eb9a85c6cd6783bf7f6e \
- --hash=sha256:6601d812105583948ab9c6e403a7e2dba6e387cc678c010e74f2d6d589d1d1b3 \
- --hash=sha256:6b6d60b0958be711bab047e9f4df5dbbc40367955f8651232bfdcdd21450b9ab \
- --hash=sha256:6beb84f83360ff29a3654f43f251ec11b809dcb5524b698d711550243debd289 \
- --hash=sha256:752d2949b40e12e6ad3ed8cc552a65b54d226504f6b1fb67cab2ccee502cc06f \
- --hash=sha256:7dc8584ca6c015ad82e186e82f4c0fe977394588f66b8ecfc4ec873285314619 \
- --hash=sha256:82434ba3a5935e47908bc861ce1ebc43c2edfc1001d235d6e31e5d3ed55815f7 \
- --hash=sha256:8270d1dc2c98ab57e6dbf36fa187db8df4c036f04a398e5d5e25b4e01a766d70 \
- --hash=sha256:8a48fd3a7222be226bb86b7b413ad248f17f3101a524018cdc4562eeae1eb2a3 \
- --hash=sha256:95952d3fe795b06af29bb8ec7bbf3342cdd867fc17b77cc25e6733d23fa6c519 \
- --hash=sha256:976a7f24eb213e8429cab78d5e120500dfcdeb01041f1f5a77b17b9101902615 \
- --hash=sha256:9c84a481451e7174f3a764a44150f93b041ab51045aa33d7b5b68b6979114e48 \
- --hash=sha256:a34d6e905f071f9b945cabbcc776e2055de1fdb59cd13683d9aa0a8f265b5bf9 \
- --hash=sha256:a4952899b4931a6ba12951f9a141ef3e74ff8a6ec9aa2dc602afa40f63595e33 \
- --hash=sha256:a96c3c7f564b263c5d7c0e49a337166c8611e89c4c919f66dba7b9a84abad137 \
- --hash=sha256:aef7d30242409c3aa5839b501e877e453a2c8d3759ca8230dd5a21cda029f046 \
- --hash=sha256:b5bd026ac928c96cc23149e6ef79183125542062eb6d1ccec34c0a37e02255e7 \
- --hash=sha256:b6a2ead3de3b2d53119d473aa2f224030257ef33af1e4ddabd4afee1dea5f04c \
- --hash=sha256:ba074af9ca268ad7b05d3fc2b920b5fb3c083da94ab63637aaf67f4f71ecb755 \
- --hash=sha256:c5fb6f3d7824696c1c9f2ad36ddb080ba5a86f2d929ef712d511b4d9972d3d27 \
- --hash=sha256:c705e0c21acb0e8478a00e7e773ad0ecdb34bd0e4adc282d3d2f51ba3961aac7 \
- --hash=sha256:c7ad9fbedb93f331c2e9054e202e95cf825b885811f1bcbbdfdc301e451442db \
- --hash=sha256:da95778d37be8e4e9afca771a83424f892296f5dfb2a100eda2571a1d8bbc0dc \
- --hash=sha256:dad5b302a4c21c604d88a5d441973f320134e6ff6a84ecef9c1139e5ffd466f6 \
- --hash=sha256:dbc1ba968639c1d23476f75c356e549e7bbf2d8d6688717dcab5290e88e8482b \
- --hash=sha256:ddb2511fbbb440ed9e5c9a4b9b870f2ed649b7715859fd6f2ebc585ee85c0364 \
- --hash=sha256:df9ba1183b3f649210788cf80c239041dddcb375d6142d8bccafcfdf549522cd \
- --hash=sha256:e4f513d63df6336fd84b74b701f17d1bb3b64e9d78a6ed5b5e8a198bbbe8bbfa \
- --hash=sha256:e6f90698b5d1c5dd7b3236cd1fa959d7b80e17923f918d5be020b65f1c78b173 \
- --hash=sha256:eaf8e3b97caaf9415227a3c6ca5aa8d800fecadd526538d2bf8f11af783f1550 \
- --hash=sha256:ee81349411648d1abc94095c68cd25e3c2812e4e0367f9a9355be1e804a5135c \
- --hash=sha256:f144a790f14c51b8a8e591eb5af40507ffee45ea6b818c2482f0457fec2e1a2e \
- --hash=sha256:f3e837d29f0e1b9d6e7b29d569e2e9b0da61889e41879832ea15569c251c303a \
- --hash=sha256:fa8eaac75d3107e3f5465f2c9e3bbd13db21790c6e45b7de1756eba16b050aca \
- --hash=sha256:fdc6191587de410a184550d4143e2b24a14df495c86ca15e59508710681690ac
+grpcio==1.53.2 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:07b83c06e7d113044cf3da15ca52f578c5f3dca299af711e9a589c1b71eb8be5 \
+ --hash=sha256:0c9e42f2499c8603af1d88771dc97e2c6b0310c278337058fd7fd1ddb35ab853 \
+ --hash=sha256:0e92dc6a85cd1de42527812ef1276095e62169d002d86c888b6e889fcda1dd29 \
+ --hash=sha256:0f76287d98ec79a38cba8292d0bdcd6ab9b9daf568dce1d53b9eb0135fc14d26 \
+ --hash=sha256:18afdda2bbe0c615da4daff754cab0df9bbd859c415d85e7e741a2975b3208b4 \
+ --hash=sha256:1af074f28a56425e4f4d99761708981543a27ae963f5b4b0a36ff71f3483479d \
+ --hash=sha256:1d1a320230e0d020880178b8eb453300bd57700b44c3744268370502e7376a9b \
+ --hash=sha256:1deeb84bb344351434f999cea4704ac6f1e07b3d861e34c44b50d8afa06caaa1 \
+ --hash=sha256:1df931fbb4c36363d2cb985c2c26fda8f060b541a89c6c1191fdb59151a8c934 \
+ --hash=sha256:1fcced1abb13cdb6a5d8b105765d30212a6cb29ab0dfb01eedecf2ff6c84371b \
+ --hash=sha256:24c63592103fded38b258f1e520ba8b0a7a0bbc397cddd6520a1f74dc4b5dec0 \
+ --hash=sha256:2b4f5671f9e88b7f51f54adda37a23277b7fdebd1557c47543b3e8a8044dd510 \
+ --hash=sha256:33f7678287ac330c94e25f96cdb951e0861e206115ba4d8ea66cf6546b1a09d0 \
+ --hash=sha256:3b789472e9ef75d179295d0c6a1f7f0aefd08189cd1c822b068b0523365a1dbe \
+ --hash=sha256:431f864f2642a97d0aa8c6b606c307f03d22f919b1a226af90488426aed35809 \
+ --hash=sha256:504af9e86ab01c9c33d8a452fe846aa931d024945f2e897537ccb8f7d76778ee \
+ --hash=sha256:53d34cbf212f03634d74ba366d595b4a06a3b60fcc731eddbd6fd7ebe4acf981 \
+ --hash=sha256:590c7206f764cfe37a65003a75977358e20919ed488f970935f54efa2741b497 \
+ --hash=sha256:5b403c4ad22f3ba37c7720547d8888a1e4b74ad980a94332bbbc50330b623abc \
+ --hash=sha256:5b49f372df33f5f84865aef5d46cacd23180b586c80e8cbe0ce149b96dfa8c4c \
+ --hash=sha256:6275a54b41d6b1ec539b019bc3affaf6d05b0a0ba36af1a65b8a2810ef69e07d \
+ --hash=sha256:69e99fe6bdc2cdacd04cef6b6585b00630d958c98e36d825de3eea406e15fb31 \
+ --hash=sha256:6be86e8d5cf47415968588e5dfbfb92ee8757fb41139584192b67050d1a72c58 \
+ --hash=sha256:712113946b303db9ae4245a13de213710367850a6c3c53530b70e87989feb8e0 \
+ --hash=sha256:7734d1b91f1f3b1f186debf8ec4d168ee088a54e8186c14d89a95f7e51d3198d \
+ --hash=sha256:7b44ed75b9d67d17e5a098a0f99a8fd3e5861fd3c4eb54212277a0acdf298434 \
+ --hash=sha256:7cbf1e3aaec3edf734ef90182363a395d234cd4790544be914cedbe1b9fec99a \
+ --hash=sha256:7e6885a8431939f1ee547e965fa3cb801a518b83d3d3509e90dbef78f0b5fd29 \
+ --hash=sha256:7ea235cecb9df14b49a75cbd27a634683a96bb76576363407ec820ae454ce2b2 \
+ --hash=sha256:80a8867746cff41c2db436dd9eea18ebbfcd0449d65b64b3ed3c995207898971 \
+ --hash=sha256:8166ac6671472d172cc0db50323b7a7504bd534de54aa31354465a00ca44409d \
+ --hash=sha256:8fc7667564c8c15748354dea1bb4035c5118df4e9dc5154ccdb6e62a3e5a2bac \
+ --hash=sha256:9efbedc737ba342d8a2459afc9bd5c5df31adcdf774b772a4e663739f2cf0d06 \
+ --hash=sha256:a3bee217bda6b2c81d9e2866f523217135a03a007a89043eee074e93d76706b0 \
+ --hash=sha256:b16258a31269b97e26a08d71b5deb56499e86077d26e453fad8f6ec4c06fe666 \
+ --hash=sha256:b676c4365a5753bc8c49f922a5f88bdb5df6746c670a9d859d2ba2f5f97d9269 \
+ --hash=sha256:bea6a20c5a732a27b64623d43614b3022e6fcfc081a75236b7f9aa069d2eaa4d \
+ --hash=sha256:d406cf2f6ccf39883a24b048c448a37bac16939408c1b6fbb4d021f3cd961448 \
+ --hash=sha256:d9c51ca201326b49cfee38336c6e7dd1cb8a6b6d0dcf84aeaecbae310a736dbc \
+ --hash=sha256:df07843c8c0dc71a56d3af3dfe19165fb0d3af7d3354a72185f6fa1b4ac05cab \
+ --hash=sha256:ea84becb5cbd6a94a810c5214eb263ae57e915a9ed1bdcd5b4a6baf13d8c5177 \
+ --hash=sha256:f14a82d12d53eb93298c35edf88d8c3ef37243b95f94dd3c75fddcba575d34ab \
+ --hash=sha256:f3761f9a6817e32898eaa5aecd0b0ad69d0c68ab45ea7bf206e8dc4548f025f0 \
+ --hash=sha256:f7e66d8b31ef2bada7029275debbe12c97397ec7ac70a659837a7b8a6a9dc916 \
+ --hash=sha256:f9f7c0dd17f24e1774cc3a8df738246772994e853c28b28ed6ba7711ccf0abb4
hdn-research-environment==2.3.8 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:371b33950e3c1598b650edefd42fa19a3532d2ca815dd42f495f0460c57df97f
html2text==2018.1.9 ; python_version >= "3.9" and python_version < "4.0" \
@@ -318,11 +324,18 @@ httplib2==0.19.1 ; python_version >= "3.9" and python_version < "4.0" \
idna==2.10 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6 \
--hash=sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0
+importlib-metadata==7.0.2 ; python_version >= "3.9" and python_version < "3.10" \
+ --hash=sha256:198f568f3230878cb1b44fbd7975f87906c22336dba2e4a7f05278c281fbd792 \
+ --hash=sha256:f4bc4c0c070c490abf4ce96d715f68e95923320370efb66143df00199bb6c100
+jinja2==3.1.3 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa \
+ --hash=sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90
jmespath==1.0.1 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \
--hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe
-jwcrypto==1.4.2 ; python_version >= "3.9" and python_version < "4.0" \
- --hash=sha256:80a35e9ed1b3b2c43ce03d92c5d48e6d0b6647e2aa2618e4963448923d78a37b
+jwcrypto==1.5.6 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789 \
+ --hash=sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039
libsass==0.21.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:06c8776417fe930714bdc930a3d7e795ae3d72be6ac883ff72a1b8f7c49e5ffb \
--hash=sha256:12f39712de38689a8b785b7db41d3ba2ea1d46f9379d81ea4595802d91fa6529 \
@@ -334,6 +347,70 @@ libsass==0.21.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:c9ec490609752c1d81ff6290da33485aa7cb6d7365ac665b74464c1b7d97f7da \
--hash=sha256:d5ba529d9ce668be9380563279f3ffe988f27bc5b299c5a28453df2e0b0fbaf2 \
--hash=sha256:e2b1a7d093f2e76dc694c17c0c285e846d0b0deb0e8b21dc852ba1a3a4e2f1d6
+markupsafe==2.1.3 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e \
+ --hash=sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e \
+ --hash=sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431 \
+ --hash=sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686 \
+ --hash=sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c \
+ --hash=sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559 \
+ --hash=sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc \
+ --hash=sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb \
+ --hash=sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939 \
+ --hash=sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c \
+ --hash=sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0 \
+ --hash=sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4 \
+ --hash=sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9 \
+ --hash=sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575 \
+ --hash=sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba \
+ --hash=sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d \
+ --hash=sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd \
+ --hash=sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3 \
+ --hash=sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00 \
+ --hash=sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155 \
+ --hash=sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac \
+ --hash=sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52 \
+ --hash=sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f \
+ --hash=sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8 \
+ --hash=sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b \
+ --hash=sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007 \
+ --hash=sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24 \
+ --hash=sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea \
+ --hash=sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198 \
+ --hash=sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0 \
+ --hash=sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee \
+ --hash=sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be \
+ --hash=sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2 \
+ --hash=sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1 \
+ --hash=sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707 \
+ --hash=sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6 \
+ --hash=sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c \
+ --hash=sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58 \
+ --hash=sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823 \
+ --hash=sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779 \
+ --hash=sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636 \
+ --hash=sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c \
+ --hash=sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad \
+ --hash=sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee \
+ --hash=sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc \
+ --hash=sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2 \
+ --hash=sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48 \
+ --hash=sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7 \
+ --hash=sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e \
+ --hash=sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b \
+ --hash=sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa \
+ --hash=sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5 \
+ --hash=sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e \
+ --hash=sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb \
+ --hash=sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9 \
+ --hash=sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57 \
+ --hash=sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc \
+ --hash=sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc \
+ --hash=sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2 \
+ --hash=sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11
+moto==4.2.10 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:5cf0736d1f43cb887498d00b00ae522774bfddb7db1f4994fedea65b290b9f0e \
+ --hash=sha256:92595fe287474a31ac3ef847941ebb097e8ffb0c3d6c106e47cf573db06933b2
oauthlib==3.2.2 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \
--hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918
@@ -343,65 +420,79 @@ packaging==20.9 ; python_version >= "3.9" and python_version < "4.0" \
pdfminer-six==20211012 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:0351f17d362ee2d48b158be52bcde6576d96460efd038a3e89a043fba6d634d7 \
--hash=sha256:d3efb75c0249b51c1bf795e3a8bddf1726b276c77bf75fb136adea471ee2825b
-pillow==10.0.1 ; python_version >= "3.9" and python_version < "4.0" \
- --hash=sha256:0462b1496505a3462d0f35dc1c4d7b54069747d65d00ef48e736acda2c8cbdff \
- --hash=sha256:186f7e04248103482ea6354af6d5bcedb62941ee08f7f788a1c7707bc720c66f \
- --hash=sha256:19e9adb3f22d4c416e7cd79b01375b17159d6990003633ff1d8377e21b7f1b21 \
- --hash=sha256:28444cb6ad49726127d6b340217f0627abc8732f1194fd5352dec5e6a0105635 \
- --hash=sha256:2872f2d7846cf39b3dbff64bc1104cc48c76145854256451d33c5faa55c04d1a \
- --hash=sha256:2cc6b86ece42a11f16f55fe8903595eff2b25e0358dec635d0a701ac9586588f \
- --hash=sha256:2d7e91b4379f7a76b31c2dda84ab9e20c6220488e50f7822e59dac36b0cd92b1 \
- --hash=sha256:2fa6dd2661838c66f1a5473f3b49ab610c98a128fc08afbe81b91a1f0bf8c51d \
- --hash=sha256:32bec7423cdf25c9038fef614a853c9d25c07590e1a870ed471f47fb80b244db \
- --hash=sha256:3855447d98cced8670aaa63683808df905e956f00348732448b5a6df67ee5849 \
- --hash=sha256:3a04359f308ebee571a3127fdb1bd01f88ba6f6fb6d087f8dd2e0d9bff43f2a7 \
- --hash=sha256:3a0d3e54ab1df9df51b914b2233cf779a5a10dfd1ce339d0421748232cea9876 \
- --hash=sha256:44e7e4587392953e5e251190a964675f61e4dae88d1e6edbe9f36d6243547ff3 \
- --hash=sha256:459307cacdd4138edee3875bbe22a2492519e060660eaf378ba3b405d1c66317 \
- --hash=sha256:4ce90f8a24e1c15465048959f1e94309dfef93af272633e8f37361b824532e91 \
- --hash=sha256:50bd5f1ebafe9362ad622072a1d2f5850ecfa44303531ff14353a4059113b12d \
- --hash=sha256:522ff4ac3aaf839242c6f4e5b406634bfea002469656ae8358644fc6c4856a3b \
- --hash=sha256:552912dbca585b74d75279a7570dd29fa43b6d93594abb494ebb31ac19ace6bd \
- --hash=sha256:5d6c9049c6274c1bb565021367431ad04481ebb54872edecfcd6088d27edd6ed \
- --hash=sha256:697a06bdcedd473b35e50a7e7506b1d8ceb832dc238a336bd6f4f5aa91a4b500 \
- --hash=sha256:71671503e3015da1b50bd18951e2f9daf5b6ffe36d16f1eb2c45711a301521a7 \
- --hash=sha256:723bd25051454cea9990203405fa6b74e043ea76d4968166dfd2569b0210886a \
- --hash=sha256:764d2c0daf9c4d40ad12fbc0abd5da3af7f8aa11daf87e4fa1b834000f4b6b0a \
- --hash=sha256:787bb0169d2385a798888e1122c980c6eff26bf941a8ea79747d35d8f9210ca0 \
- --hash=sha256:7f771e7219ff04b79e231d099c0a28ed83aa82af91fd5fa9fdb28f5b8d5addaf \
- --hash=sha256:847e8d1017c741c735d3cd1883fa7b03ded4f825a6e5fcb9378fd813edee995f \
- --hash=sha256:84efb46e8d881bb06b35d1d541aa87f574b58e87f781cbba8d200daa835b42e1 \
- --hash=sha256:898f1d306298ff40dc1b9ca24824f0488f6f039bc0e25cfb549d3195ffa17088 \
- --hash=sha256:8b451d6ead6e3500b6ce5c7916a43d8d8d25ad74b9102a629baccc0808c54971 \
- --hash=sha256:8f06be50669087250f319b706decf69ca71fdecd829091a37cc89398ca4dc17a \
- --hash=sha256:92a23b0431941a33242b1f0ce6c88a952e09feeea9af4e8be48236a68ffe2205 \
- --hash=sha256:93139acd8109edcdeffd85e3af8ae7d88b258b3a1e13a038f542b79b6d255c54 \
- --hash=sha256:98533fd7fa764e5f85eebe56c8e4094db912ccbe6fbf3a58778d543cadd0db08 \
- --hash=sha256:9f665d1e6474af9f9da5e86c2a3a2d2d6204e04d5af9c06b9d42afa6ebde3f21 \
- --hash=sha256:b059ac2c4c7a97daafa7dc850b43b2d3667def858a4f112d1aa082e5c3d6cf7d \
- --hash=sha256:b1be1c872b9b5fcc229adeadbeb51422a9633abd847c0ff87dc4ef9bb184ae08 \
- --hash=sha256:b7cf63d2c6928b51d35dfdbda6f2c1fddbe51a6bc4a9d4ee6ea0e11670dd981e \
- --hash=sha256:bc2e3069569ea9dbe88d6b8ea38f439a6aad8f6e7a6283a38edf61ddefb3a9bf \
- --hash=sha256:bcf1207e2f2385a576832af02702de104be71301c2696d0012b1b93fe34aaa5b \
- --hash=sha256:ca26ba5767888c84bf5a0c1a32f069e8204ce8c21d00a49c90dabeba00ce0145 \
- --hash=sha256:cbe68deb8580462ca0d9eb56a81912f59eb4542e1ef8f987405e35a0179f4ea2 \
- --hash=sha256:d6caf3cd38449ec3cd8a68b375e0c6fe4b6fd04edb6c9766b55ef84a6e8ddf2d \
- --hash=sha256:d72967b06be9300fed5cfbc8b5bafceec48bf7cdc7dab66b1d2549035287191d \
- --hash=sha256:d889b53ae2f030f756e61a7bff13684dcd77e9af8b10c6048fb2c559d6ed6eaf \
- --hash=sha256:de596695a75496deb3b499c8c4f8e60376e0516e1a774e7bc046f0f48cd620ad \
- --hash=sha256:e6a90167bcca1216606223a05e2cf991bb25b14695c518bc65639463d7db722d \
- --hash=sha256:ed2d9c0704f2dc4fa980b99d565c0c9a543fe5101c25b3d60488b8ba80f0cce1 \
- --hash=sha256:ee7810cf7c83fa227ba9125de6084e5e8b08c59038a7b2c9045ef4dde61663b4 \
- --hash=sha256:f0b4b06da13275bc02adfeb82643c4a6385bd08d26f03068c2796f60d125f6f2 \
- --hash=sha256:f11c9102c56ffb9ca87134bd025a43d2aba3f1155f508eff88f694b33a9c6d19 \
- --hash=sha256:f5bb289bb835f9fe1a1e9300d011eef4d69661bb9b34d5e196e5e82c4cb09b37 \
- --hash=sha256:f6d3d4c905e26354e8f9d82548475c46d8e0889538cb0657aa9c6f0872a37aa4 \
- --hash=sha256:fcb59711009b0168d6ee0bd8fb5eb259c4ab1717b2f538bbf36bacf207ef7a68 \
- --hash=sha256:fd2a5403a75b54661182b75ec6132437a181209b901446ee5724b589af8edef1
+pillow==10.2.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8 \
+ --hash=sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39 \
+ --hash=sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac \
+ --hash=sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869 \
+ --hash=sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e \
+ --hash=sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04 \
+ --hash=sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9 \
+ --hash=sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e \
+ --hash=sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe \
+ --hash=sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef \
+ --hash=sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56 \
+ --hash=sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa \
+ --hash=sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f \
+ --hash=sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f \
+ --hash=sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e \
+ --hash=sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a \
+ --hash=sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2 \
+ --hash=sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2 \
+ --hash=sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5 \
+ --hash=sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a \
+ --hash=sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2 \
+ --hash=sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213 \
+ --hash=sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563 \
+ --hash=sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591 \
+ --hash=sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c \
+ --hash=sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2 \
+ --hash=sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb \
+ --hash=sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757 \
+ --hash=sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0 \
+ --hash=sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452 \
+ --hash=sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad \
+ --hash=sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01 \
+ --hash=sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f \
+ --hash=sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5 \
+ --hash=sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61 \
+ --hash=sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e \
+ --hash=sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b \
+ --hash=sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068 \
+ --hash=sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9 \
+ --hash=sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588 \
+ --hash=sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483 \
+ --hash=sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f \
+ --hash=sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67 \
+ --hash=sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7 \
+ --hash=sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311 \
+ --hash=sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6 \
+ --hash=sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72 \
+ --hash=sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6 \
+ --hash=sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129 \
+ --hash=sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13 \
+ --hash=sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67 \
+ --hash=sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c \
+ --hash=sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516 \
+ --hash=sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e \
+ --hash=sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e \
+ --hash=sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364 \
+ --hash=sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023 \
+ --hash=sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1 \
+ --hash=sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04 \
+ --hash=sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d \
+ --hash=sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a \
+ --hash=sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7 \
+ --hash=sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb \
+ --hash=sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4 \
+ --hash=sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e \
+ --hash=sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1 \
+ --hash=sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48 \
+ --hash=sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868
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 >= "3.9" and python_version < "4.0" \
+protobuf==3.20.3 ; python_version < "4.0" and python_version >= "3.9" \
--hash=sha256:03038ac1cfbc41aa21f6afcbcd357281d7521b4157926f30ebecc8d4ea59dcb7 \
--hash=sha256:28545383d61f55b57cf4df63eebd9827754fd2dc25f80c5253f9184235db242c \
--hash=sha256:2e3427429c9cffebf259491be0af70189607f365c2f41c7c3764af6f337105f2 \
@@ -422,6 +513,7 @@ protobuf==3.20.3 ; python_version >= "3.9" and python_version < "4.0" \
--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" \
@@ -439,11 +531,33 @@ 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:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74 \
+ --hash=sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb \
+ --hash=sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45 \
+ --hash=sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd \
+ --hash=sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0 \
+ --hash=sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d \
+ --hash=sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405
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:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba
+ --hash=sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00 \
+ --hash=sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8 \
+ --hash=sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86 \
+ --hash=sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12 \
+ --hash=sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776 \
+ --hash=sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba \
+ --hash=sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2 \
+ --hash=sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3
pycparser==2.20 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0 \
--hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705
@@ -465,15 +579,71 @@ python-json-logger==2.0.2 ; python_version >= "3.9" and python_version < "4.0" \
pytz==2022.1 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7 \
--hash=sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c
+pyyaml==6.0.1 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5 \
+ --hash=sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc \
+ --hash=sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df \
+ --hash=sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741 \
+ --hash=sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206 \
+ --hash=sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27 \
+ --hash=sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595 \
+ --hash=sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62 \
+ --hash=sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98 \
+ --hash=sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696 \
+ --hash=sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290 \
+ --hash=sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9 \
+ --hash=sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d \
+ --hash=sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6 \
+ --hash=sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867 \
+ --hash=sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47 \
+ --hash=sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486 \
+ --hash=sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6 \
+ --hash=sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3 \
+ --hash=sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007 \
+ --hash=sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938 \
+ --hash=sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0 \
+ --hash=sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c \
+ --hash=sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735 \
+ --hash=sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d \
+ --hash=sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28 \
+ --hash=sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4 \
+ --hash=sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba \
+ --hash=sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8 \
+ --hash=sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef \
+ --hash=sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5 \
+ --hash=sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd \
+ --hash=sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3 \
+ --hash=sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0 \
+ --hash=sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515 \
+ --hash=sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c \
+ --hash=sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c \
+ --hash=sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924 \
+ --hash=sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34 \
+ --hash=sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43 \
+ --hash=sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859 \
+ --hash=sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673 \
+ --hash=sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54 \
+ --hash=sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a \
+ --hash=sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b \
+ --hash=sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab \
+ --hash=sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa \
+ --hash=sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c \
+ --hash=sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585 \
+ --hash=sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d \
+ --hash=sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f
requests-mock==1.9.3 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970 \
--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:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \
+ --hash=sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc
requests==2.31.0 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \
--hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1
+responses==0.24.1 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:a2b43f4c08bfb9c9bd242568328c65a34b318741d3fab884ac843c5ceeb543f9 \
+ --hash=sha256:b127c6ca3f8df0eb9cc82fd93109a3007a86acb24871834c47b77765152ecf8c
rsa==4.7.2 ; python_version >= "3.9" and python_version < "4" \
--hash=sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2 \
--hash=sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9
@@ -495,6 +665,9 @@ six==1.16.0 ; python_version >= "3.9" and python_version < "4.0" \
sqlparse==0.4.4 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3 \
--hash=sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c
+typing-extensions==4.10.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475 \
+ --hash=sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb
tzdata==2022.7 ; python_version >= "3.9" and python_version < "4.0" and sys_platform == "win32" \
--hash=sha256:2b88858b0e3120792a3c0635c23daf36a7d7eeeca657c323da299d2094402a0d \
--hash=sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa
@@ -509,81 +682,14 @@ uwsgi==2.0.22 ; python_version >= "3.9" and python_version < "4.0" \
webencodings==0.5.1 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
--hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923
-wrapt==1.15.0 ; python_version >= "3.9" and python_version < "4.0" \
- --hash=sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0 \
- --hash=sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420 \
- --hash=sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a \
- --hash=sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c \
- --hash=sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079 \
- --hash=sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923 \
- --hash=sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f \
- --hash=sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1 \
- --hash=sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8 \
- --hash=sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86 \
- --hash=sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0 \
- --hash=sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364 \
- --hash=sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e \
- --hash=sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c \
- --hash=sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e \
- --hash=sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c \
- --hash=sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727 \
- --hash=sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff \
- --hash=sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e \
- --hash=sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29 \
- --hash=sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7 \
- --hash=sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72 \
- --hash=sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475 \
- --hash=sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a \
- --hash=sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317 \
- --hash=sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2 \
- --hash=sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd \
- --hash=sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640 \
- --hash=sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98 \
- --hash=sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248 \
- --hash=sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e \
- --hash=sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d \
- --hash=sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec \
- --hash=sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1 \
- --hash=sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e \
- --hash=sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9 \
- --hash=sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92 \
- --hash=sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb \
- --hash=sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094 \
- --hash=sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46 \
- --hash=sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29 \
- --hash=sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd \
- --hash=sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705 \
- --hash=sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8 \
- --hash=sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975 \
- --hash=sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb \
- --hash=sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e \
- --hash=sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b \
- --hash=sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418 \
- --hash=sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019 \
- --hash=sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1 \
- --hash=sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba \
- --hash=sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6 \
- --hash=sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2 \
- --hash=sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3 \
- --hash=sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7 \
- --hash=sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752 \
- --hash=sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416 \
- --hash=sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f \
- --hash=sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1 \
- --hash=sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc \
- --hash=sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145 \
- --hash=sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee \
- --hash=sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a \
- --hash=sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7 \
- --hash=sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b \
- --hash=sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653 \
- --hash=sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0 \
- --hash=sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90 \
- --hash=sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29 \
- --hash=sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6 \
- --hash=sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034 \
- --hash=sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09 \
- --hash=sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559 \
- --hash=sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639
+werkzeug==3.0.1 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc \
+ --hash=sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10
+xmltodict==0.13.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56 \
+ --hash=sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852
+zipp==3.18.0 ; python_version >= "3.9" and python_version < "3.10" \
+ --hash=sha256:c1bb803ed69d2cce2373152797064f7e79bc43f0a3748eb494096a867e0ebf79 \
+ --hash=sha256:df8d042b02765029a09b157efd8e820451045890acc30f8e37dd2f94a060221f
zxcvbn==4.4.28 ; python_version >= "3.9" and python_version < "4.0" \
--hash=sha256:151bd816817e645e9064c354b13544f85137ea3320ca3be1fb6873ea75ef7dc1