diff --git a/.env.example b/.env.example index ff738bf0f4..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 @@ -98,8 +114,6 @@ GCS_SIGNED_URL_LIFETIME_IN_MINUTES=1440 # GCP Research Environments ENABLE_CLOUD_RESEARCH_ENVIRONMENTS="False" CLOUD_RESEARCH_ENVIRONMENTS_API_URL="https://example.api" -CLOUD_RESEARCH_ENVIRONMENTS_API_JWT_SERVICE_ACCOUNT_PATH="environment/api/tests/service_account.json" -CLOUD_RESEARCH_ENVIRONMENTS_API_JWT_AUDIENCE="https://example.com" # Site-specific content SITE_NAME="DataShare" @@ -160,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 @@ -187,7 +205,7 @@ MIN_WORDS_RESEARCH_SUMMARY_CREDENTIALING = 20 # CITISOAPService API # This is the WebServices username and password to access the CITI SOAP Service to obtain users training report details # The account can be created at https://webservices.citiprogram.org/login/CreateAccount.aspx -# The SOAP Service Access can be tested at https://webservices.citiprogram.org/Client/CITISOAPClient_Simple.aspx +# The SOAP Service Access can be tested at https://webservices.citiprogram.org/Client/CITISOAPClient_Simple.aspx CITI_USERNAME= CITI_PASSWORD= CITI_SOAP_URL="https://webservices.citiprogram.org/SOAP/CITISOAPService.asmx" diff --git a/.github/workflows/physionet-build-test.yml b/.github/workflows/physionet-build-test.yml index a690cb846d..8f0bc193b5 100644 --- a/.github/workflows/physionet-build-test.yml +++ b/.github/workflows/physionet-build-test.yml @@ -14,7 +14,7 @@ jobs: test: name: Test runs-on: ubuntu-latest - container: debian:11 + container: ${{ matrix.container }} env: DJANGO_SETTINGS_MODULE: physionet.settings.settings TEST_GCS_INTEGRATION: false @@ -26,6 +26,7 @@ jobs: fail-fast: false matrix: pip3: ['poetry', 'requirements.txt'] + container: ['debian:11', 'debian:12'] steps: - name: Update packages @@ -96,7 +97,7 @@ jobs: - name: Install and setup lightwave run: | - wget https://github.com/bemoody/lightwave/archive/0.71.tar.gz -O lightwave.tar.gz + wget https://github.com/bemoody/lightwave/archive/0.72.tar.gz -O lightwave.tar.gz tar -xf lightwave.tar.gz (cd lightwave-* && make CGIDIR=/usr/local/bin sandboxed-server) diff --git a/.github/workflows/physionet-upgrade-test.yml b/.github/workflows/physionet-upgrade-test.yml index 88aeba6308..3fbca0fc3e 100644 --- a/.github/workflows/physionet-upgrade-test.yml +++ b/.github/workflows/physionet-upgrade-test.yml @@ -12,7 +12,10 @@ jobs: testupgrade: name: Upgrade Test runs-on: ubuntu-latest - container: debian:11 + container: ${{ matrix.container }} + strategy: + matrix: + container: ['debian:11', 'debian:12'] steps: - name: Install dependencies run: | @@ -53,7 +56,7 @@ jobs: - name: Install lightwave run: | - wget https://github.com/bemoody/lightwave/archive/0.71.tar.gz \ + wget https://github.com/bemoody/lightwave/archive/0.72.tar.gz \ -O lightwave.tar.gz tar -xf lightwave.tar.gz (cd lightwave-* && make CGIDIR=/usr/local/bin sandboxed-server) diff --git a/Dockerfile b/Dockerfile index e902f5b7be..49b3bec166 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN wget https://github.com/bemoody/wfdb/archive/10.7.0.tar.gz -O wfdb.tar.gz \ && ldconfig \ && rm -rf wfdb* -RUN wget https://github.com/bemoody/lightwave/archive/0.71.tar.gz -O lightwave.tar.gz \ +RUN wget https://github.com/bemoody/lightwave/archive/0.72.tar.gz -O lightwave.tar.gz \ && tar -xf lightwave.tar.gz \ && (cd lightwave-* && make sandboxed-lightwave && mkdir -p /usr/local/bin && install -m 4755 sandboxed-lightwave /usr/local/bin) \ && rm -rf lightwave* 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/README.md b/deploy/README.md index 3860114ab6..c381fc3dd2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -100,8 +100,8 @@ cd /physionet # Copy over the .env file into /physionet/physionet-build scp /.env /physionet/physionet-build/ # The software folder should be owned by the dedicated user. The socket file directory should be accessible by nginx. -chown -R pn.pn /physionet -chown pn.www-data /physionet/deploy +chown -R pn:pn /physionet +chown pn:www-data /physionet/deploy chmod g+w /physionet/deploy # Make the static and media roots mkdir /data @@ -109,7 +109,7 @@ mkdir /data/pn-static mkdir /data/pn-static/published-projects mkdir /data/pn-media mkdir /data/pn-media/{active-projects,archived-projects,credential-applications,published-projects,users} -chown -R pn.pn /data/{pn-media,pn-static} +chown -R pn:pn /data/{pn-media,pn-static} ``` The directory structure for the site's software and files will be: 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 87a43ac253..a48652a0cf 100755 --- a/deploy/test-server/install-pn-test-server +++ b/deploy/test-server/install-pn-test-server @@ -90,7 +90,7 @@ printf '%s\n%s\n' "$DBPASSWORD" "$DBPASSWORD" | su postgres -c 'createdb physionet -O physionet' mkdir -p /physionet /data/pn-static /data/pn-media -chown pn.pn /physionet /data/pn-static /data/pn-media +chown pn:pn /physionet /data/pn-static /data/pn-media su pn -c ' set -e umask 0002 @@ -153,17 +153,17 @@ su pn -c ' yes | python3 manage.py loaddemo ' -chown www-data.www-data -R /physionet/deploy -chown www-data.www-data -R /data/pn-media -chown www-data.www-data -R /data/pn-static/published-projects +chown www-data:www-data -R /physionet/deploy +chown www-data:www-data -R /data/pn-media +chown www-data:www-data -R /data/pn-static/published-projects rm /etc/nginx/sites-enabled/default ln -s ../sites-available/physionet_nginx.conf \ /etc/nginx/sites-enabled/physionet_nginx.conf mkdir /data/log /data/log/nginx /data/log/uwsgi /data/log/pn -chown www-data.root /data/log/uwsgi -chown pn.root /data/log/pn +chown www-data:root /data/log/uwsgi +chown pn:root /data/log/pn ( cd /physionet/physionet-build/deploy/common @@ -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 74f86d7923..943bd5c33f 100644 --- a/physionet-django/console/forms.py +++ b/physionet-django/console/forms.py @@ -1,10 +1,5 @@ -import pdb import re -from django.forms.widgets import RadioSelect - -from django.forms.widgets import RadioSelect - from console.utility import generate_doi_payload, register_doi from dal import autocomplete from django import forms @@ -27,9 +22,10 @@ PublishedAffiliation, PublishedAuthor, PublishedProject, + PublishedPublication, + SubmissionStatus, exists_project_slug, ) -from project.projectfiles import ProjectFiles from project.validators import MAX_PROJECT_SLUG_LENGTH, validate_doi, validate_slug from user.models import CodeOfConduct, CredentialApplication, CredentialReview, User, TrainingQuestion @@ -91,10 +87,12 @@ def __init__(self, *args, **kwargs): .order_by('username') def clean_project(self): - pid = self.cleaned_data['project'] + pid = self.cleaned_data["project"] validate_integer(pid) - if ActiveProject.objects.get(id=pid) not in ActiveProject.objects.filter(submission_status=10): - raise forms.ValidationError('Incorrect project selected.') + if ActiveProject.objects.get(id=pid) not in ActiveProject.objects.filter( + submission_status=SubmissionStatus.NEEDS_ASSIGNMENT + ): + raise forms.ValidationError("Incorrect project selected.") return pid @@ -225,18 +223,15 @@ 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 = 30 + project.submission_status = SubmissionStatus.NEEDS_RESUBMISSION project.revision_request_datetime = now project.latest_reminder = now project.save() # Accept else: - project.submission_status = 40 + project.submission_status = SubmissionStatus.NEEDS_COPYEDIT project.editor_accept_datetime = now project.latest_reminder = now @@ -289,7 +284,7 @@ def save(self): project = copyedit_log.project now = timezone.now() copyedit_log.complete_datetime = now - project.submission_status = 50 + project.submission_status = SubmissionStatus.NEEDS_APPROVAL project.copyedit_completion_datetime = now project.latest_reminder = now copyedit_log.save() @@ -310,12 +305,12 @@ def __init__(self, project, *args, **kwargs): super().__init__(*args, **kwargs) self.project = project # No option to set slug if publishing new version - if self.project.version_order: + if self.project.is_new_version: del(self.fields['slug']) else: self.fields['slug'].initial = project.slug - if not ProjectFiles().can_make_zip(): + if not project.files.can_make_zip(): self.fields['make_zip'].disabled = True self.fields['make_zip'].required = False self.fields['make_zip'].initial = 0 @@ -637,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): @@ -705,6 +700,32 @@ def save(self): return contact +class AddPublishedPublicationForm(forms.ModelForm): + class Meta: + model = PublishedPublication + fields = ('citation', 'url') + + def __init__(self, project, *args, **kwargs): + super().__init__(*args, **kwargs) + self.project = project + + def clean(self): + cleaned_data = super().clean() + existing_publication = PublishedPublication.objects.filter(project=self.project).first() + + if existing_publication: + raise forms.ValidationError("A publication already exists for this project.") + + return cleaned_data + + def save(self, commit=True): + publication = super().save(commit=False) + publication.project = self.project + if commit: + publication.save() + return publication + + class CreateLegacyAuthorForm(forms.ModelForm): """ Create an author for a legacy project. 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 %} +
+
+ +
+
+ + + + + + + {% for platform in cloud_platforms %} + + {% endfor %} + + + + {% for project, mirrors in project_mirrors.items %} + + + + + {% for platform_mirror in mirrors %} + + {% endfor %} + + {% endfor %} + +
ProjectVersion + + {{ SITE_NAME }} + + + + {{ platform.name }} + +
+ + {{ 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 %} + + {% if not platform_mirror %} + + {% elif not platform_mirror.sent_files %} + Pending + {% elif platform_mirror.is_private %} + Private + {% else %} + Public + {% endif %} +
+
+
+{% endblock %} diff --git a/physionet-django/console/templates/console/console_navbar.html b/physionet-django/console/templates/console/console_navbar.html index c6667885b8..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 %} diff --git a/physionet-django/console/templates/console/copyedit_submission.html b/physionet-django/console/templates/console/copyedit_submission.html index 419df43f76..afbf06822c 100644 --- a/physionet-django/console/templates/console/copyedit_submission.html +++ b/physionet-django/console/templates/console/copyedit_submission.html @@ -94,7 +94,7 @@

Discovery

-{% if project.has_other_versions or project.version_order %} +{% if project.is_new_version %} {% endif %} diff --git a/physionet-django/console/templates/console/event.html b/physionet-django/console/templates/console/event_active.html similarity index 92% rename from physionet-django/console/templates/console/event.html rename to physionet-django/console/templates/console/event_active.html index 2c07f7512f..9f7add0068 100644 --- a/physionet-django/console/templates/console/event.html +++ b/physionet-django/console/templates/console/event_active.html @@ -13,7 +13,7 @@ {% block content %}
- Events {{ events.paginator.count }} + Active Events {{ event_active.paginator.count }}
@@ -32,7 +32,7 @@ - {% for event in events %} + {% for event in event_active %} {{ event.title }} {{ event.get_category_display|title }} diff --git a/physionet-django/console/templates/console/event_archive.html b/physionet-django/console/templates/console/event_archive.html new file mode 100644 index 0000000000..178dc045d0 --- /dev/null +++ b/physionet-django/console/templates/console/event_archive.html @@ -0,0 +1,55 @@ +{% extends "console/base_console.html" %} + +{% load static %} + +{% block title %}Archived Events{% endblock %} + +{% block local_css %} + +{% endblock %} + +{% load console_templatetags %} + +{% block content %} +
+
+ Archived Events {{ event_archive.paginator.count }} +
+
+
+ + + + + + + + + + + + + + + + {% for event in event_archive %} + + + + + + + + + + + + {% endfor %} + +
EventEvent TypeHostCreatedStartedEndedCredentialingTrainingManage
{{ event.title }}{{ event.get_category_display|title }}{{ event.host }}{{ event.added_datetime|date }}{{ event.start_date }}{{ event.end_date }}View listView listManage
+ {% include "console/pagination.html" with pagination=events %} +
+
+
+{% endblock %} + 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 }}
+ 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 %} + {% endfor %} + {% endblock %} diff --git a/physionet-django/console/templates/console/guidelines_course.html b/physionet-django/console/templates/console/guidelines_course.html index a094894c17..12737f25b9 100644 --- a/physionet-django/console/templates/console/guidelines_course.html +++ b/physionet-django/console/templates/console/guidelines_course.html @@ -24,7 +24,7 @@

General Schema Explanation

"description": "string", "valid_duration": "string", "courses": [{ - "version": "float", + "version": "string", "modules": [ { "contents": [ @@ -74,7 +74,8 @@

General Schema Explanation

    -
  1. Modules: A course may have one or more modules that contain the contents,quizzes for each +
  2. 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:
    • @@ -86,7 +87,7 @@

      General Schema Explanation

    -
  1. Content: A module may have one or more content. Each content should include:
  2. +
  3. Content: A module may have one or more content blocks. Each content block should include:
  4. 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/submission_info_card.html b/physionet-django/console/templates/console/submission_info_card.html index d82dac8b2e..561899bea2 100644 --- a/physionet-django/console/templates/console/submission_info_card.html +++ b/physionet-django/console/templates/console/submission_info_card.html @@ -22,7 +22,7 @@ Embargo {% endif %} - {% if project.submission_status >= 40 %} + {% if project.submission_status >= SubmissionStatus.NEEDS_COPYEDIT %} @@ -40,7 +40,7 @@

    {{ project.title }}

    Created: {{ project.creation_datetime|date }}. Submitted: {{ project.submission_datetime|date }}.
    Storage Used: {{ storage_info.readable_used }} / {{ storage_info.readable_allowance }}
    Version: {{ project.version }} - {% if project.version_order %}
    Latest Published Version: {{ latest_version.version }}{% endif %} + {% if project.is_new_version %}
    Latest Published Version: {{ latest_version.version }}{% endif %} {% if project.latest_reminder %}
    Latest reminder email sent on: {{ project.latest_reminder }} {% endif %} 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 %} + + +
    +
    + {{ training_type }} +
    + +
    +
    +
    + +
    +
    + +
    +

    Active Versions

    +
    + + + + + + + + + + + {% for course in active_course_versions %} + + + + + + + + {% endfor %} + +
    NameVersionDownloadExpire
    {{ training_type.name|title }}{{ course.version }} + Download + +
    + {% csrf_token %} + + +
    +
    +

    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

    +
    + + + + + + + + + + {% for course in inactive_course_versions %} + + + + + + + {% endfor %} + +
    NameVersionAction
    {{ training_type.name|title }}{{ course.version }} + Download +
    +
    +
    +{% 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 index 0935bead76..76f43e176c 100644 --- a/physionet-django/console/templates/console/training_type/index.html +++ b/physionet-django/console/templates/console/training_type/index.html @@ -5,12 +5,26 @@ {% block title %}{{ SITE_NAME }} Courses{% endblock %} {% block content %} -
    + + + +
    Courses {{ training_types|length }}
    -
    +
    @@ -21,51 +35,46 @@ - {% for training in training_types %} + {% for training in training_types %} - - - - - - {% endfor %} + + + + + + {% endfor %}
    {{ training.name|title }} {% for course in training.courses.all %}{{ course.version }}{% if not forloop.last %} , {% endif %}{% endfor %}{{ training.valid_duration.days }} days{{ training.courses.last.version }}
    {{ training.name|title }}{{ training.valid_duration.days }} days{{ training.courses.last.version }}
    - - {% endblock %} diff --git a/physionet-django/console/templates/console/user_management.html b/physionet-django/console/templates/console/user_management.html index 64900684ae..f82f3863ea 100644 --- a/physionet-django/console/templates/console/user_management.html +++ b/physionet-django/console/templates/console/user_management.html @@ -111,21 +111,27 @@

    Profile

    {% endfor %} - {% if groups %} -
    -
    - User Groups: -
    -
    - {% for group in groups %} - {{ group.name }} - {% empty %} - N/A - {% endfor %} -
    +
    +

    Permission groups

    +
    +
    + {% if groups %} +
    + The user is a member of the following groups:
    - - {% endif %} +
    + +
    + {% else %} +
    + The user does not belong to a permission group. +
    + {% endif %} +

    Projects

    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 cdbe89679c..a1f41a7ac1 100644 --- a/physionet-django/console/test_views.py +++ b/physionet-django/console/test_views.py @@ -11,12 +11,12 @@ from events.models import EventAgreement from project.models import ( ActiveProject, - ArchivedProject, Author, AuthorInvitation, License, PublishedProject, StorageRequest, + SubmissionStatus, ) from user.models import User from physionet.models import FrontPageButton, StaticPage @@ -55,7 +55,7 @@ def test_assign_editor(self): 'editor':editor.id}) project = ActiveProject.objects.get(title='MIT-BIH Arrhythmia Database') self.assertTrue(project.editor, editor) - self.assertEqual(project.submission_status, 20) + self.assertEqual(project.submission_status, SubmissionStatus.NEEDS_DECISION) def test_reassign_editor(self): """ @@ -71,7 +71,7 @@ def test_reassign_editor(self): 'project': project.id, 'editor': editor.id}) project = ActiveProject.objects.get(title='MIT-BIH Arrhythmia Database') self.assertTrue(project.editor, editor) - self.assertEqual(project.submission_status, 20) + self.assertEqual(project.submission_status, SubmissionStatus.NEEDS_DECISION) # Reassign editor editor = User.objects.get(username='amitupreti') @@ -96,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 43d5b3f513..59f93f1692 100644 --- a/physionet-django/console/urls.py +++ b/physionet-django/console/urls.py @@ -20,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'), @@ -80,15 +82,13 @@ 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'), @@ -149,8 +149,10 @@ ), path('code-of-conducts//activate/', views.code_of_conduct_activate, name='code_of_conduct_activate'), # Lists of event components - path('event/', views.event, - name='event'), + path('event/active/', views.event_active, + name='event_active'), + path('event/archived/', views.event_archive, + name='event_archive'), path('event/manage/', views.event_management, name='event_management'), path('event_agreements/', views.event_agreement_list, name='event_agreement_list'), path('event_agreements//', views.event_agreement_detail, name='event_agreement_detail'), @@ -160,5 +162,115 @@ # Courses/On Platform Training path('courses/', training_views.courses, name='courses'), - path('courses//download/', training_views.download_course, name='download_course'), + 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) +TEST_DEFAULTS = { + '_user_': 'admin', + 'button_pk': 1, + 'event_slug': 'iLII4L9jSDFh', + 'page_pk': 2, + 'pk': 1, + 'pid': 1, + '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': { + 'project_slug': 'demoeicu', + 'version': '2.0.0', + }, + + 'project_access_requests_detail': { + # id of a PublishedProject with access_policy=CONTRIBUTOR_REVIEW + 'pk': 4, + }, + + 'submission_info_redirect': { + 'project_slug': 'p7TCIMkltNswuOB9FZH1', + }, + 'submission_info': { + 'project_slug': 'p7TCIMkltNswuOB9FZH1', + }, + # Missing demo data (projects in appropriate submission states) + 'edit_submission': { + 'project_slug': 'xxxxxxxxxxxxxxxxxxxx', + '_skip_': True, + }, + 'copyedit_submission': { + 'project_slug': 'xxxxxxxxxxxxxxxxxxxx', + '_skip_': True, + }, + 'awaiting_authors': { + 'project_slug': 'xxxxxxxxxxxxxxxxxxxx', + '_skip_': True, + }, + 'publish_submission': { + 'project_slug': 'xxxxxxxxxxxxxxxxxxxx', + '_skip_': True, + }, + 'publish_slug_available': { + '_user_': 'tompollard', + 'project_slug': 'p7TCIMkltNswuOB9FZH1', + '_query_': {'desired_slug': 'note-parser'}, + }, + + 'credential_applications': [ + # categories defined in views.credential_applications() + {'status': 'successful'}, + {'status': 'unsucccessful'}, + {'status': 'pending'}, + ], + + 'view_credential_application': { + # slug of a CredentialApplication with any status + 'application_slug': '5rDeC8Om9dBJTeJN91y2', + }, + 'process_credential_application': { + # slug of a CredentialApplication with status=PENDING + 'application_slug': '5rDeC8Om9dBJTeJN91y2', + }, + + 'training_list': [ + # categories defined in views.training_list() + {'status': 'review'}, + {'status': 'valid'}, + {'status': 'expired'}, + {'status': 'rejected'}, + ], + 'training_process': { + # id of a Training with status=REVIEW + 'pk': 2, + }, + + 'user_group': { + # name of a Group + 'group': 'Managing%20Editor', + }, + 'users': [ + # categories defined in views.users() + {'group': 'all'}, + {'group': 'admin'}, + {'group': 'active'}, + {'group': 'inactive'}, + ], + + # Missing demo data + 'event_agreement_detail': {'_skip_': True}, + 'event_agreement_delete': {'_skip_': True}, + 'event_agreement_new_version': {'_skip_': True}, + + # Broken views: POST required for no reason + 'users_list_search': {'group': 'all', '_skip_': True}, + 'known_references_search': {'_skip_': True}, + 'news_search': {'_skip_': True}, +} diff --git a/physionet-django/console/utility.py b/physionet-django/console/utility.py index 0d789f0944..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. @@ -53,10 +52,14 @@ def create_bucket(project, version, title, protected=True): """ storage_client = storage.Client() bucket_name, email = bucket_info(project, version) - storage_client.create_bucket(bucket_name) + bucket = storage_client.bucket(bucket_name) - bucket.iam_configuration.bucket_policy_only_enabled = True - bucket.patch() + # Only bucket-level permissions are enforced; there are no per-file ACLs. + bucket.iam_configuration.uniform_bucket_level_access_enabled = True + # Clients accessing this bucket will be billed for download costs. + bucket.requester_pays = True + storage_client.create_bucket(bucket) + LOGGER.info("Created bucket {0} for project {1}".format( bucket_name.lower(), project)) if protected: @@ -485,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()}', @@ -512,6 +516,24 @@ def generate_doi_payload(project, core_project=False, event="draft"): else: relation = [] + # projects from which this project is derived + for parent_project in project.parent_projects.all(): + if parent_project.doi: + relation.append({ + "relationType": "IsDerivedFrom", + "relatedIdentifier": parent_project.doi, + "relatedIdentifierType": "DOI", + }) + else: + url = "https://{0}{1}".format(current_site, reverse( + 'published_project', + args=(parent_project.slug, parent_project.version))) + relation.append({ + "relationType": "IsDerivedFrom", + "relatedIdentifier": url, + "relatedIdentifierType": "URL", + }) + resource_type = 'Dataset' if project.resource_type.name == 'Software': resource_type = 'Software' diff --git a/physionet-django/console/views.py b/physionet-django/console/views.py index d278099148..f7cd7f4868 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, @@ -52,11 +51,11 @@ PublishedProject, Reference, StorageRequest, + SubmissionStatus, Topic, exists_project_slug, ) from project.authorization.access import can_view_project_files -from project.projectfiles import ProjectFiles from project.utility import readable_size from project.validators import MAX_PROJECT_SLUG_LENGTH from project.views import get_file_forms, get_project_file_info, process_files_post @@ -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,21 +172,21 @@ def submitted_projects(request): messages.success(request, 'The editor has been assigned') # Submitted projects - projects = ActiveProject.objects.filter(submission_status__gt=0).order_by( + projects = ActiveProject.objects.filter(submission_status__gt=SubmissionStatus.ARCHIVED).order_by( 'submission_datetime') # Separate projects by submission status # Awaiting editor assignment - assignment_projects = projects.filter(submission_status=10) + assignment_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_ASSIGNMENT) # Awaiting editor decision - decision_projects = projects.filter(submission_status=20) + decision_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_DECISION) # Awaiting author revisions - revision_projects = projects.filter(submission_status=30) + revision_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_RESUBMISSION) # Awaiting editor copyedit - copyedit_projects = projects.filter(submission_status=40) + copyedit_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_COPYEDIT) # Awaiting author approval - approval_projects = projects.filter(submission_status=50) + approval_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_APPROVAL) # Awaiting editor publish - publish_projects = projects.filter(submission_status=60) + publish_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_PUBLICATION) assign_editor_form = forms.AssignEditorForm() @@ -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 @@ -209,15 +233,15 @@ def editor_home(request): 'submission_datetime') # Awaiting editor decision - decision_projects = projects.filter(submission_status=20) + decision_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_DECISION) # Awaiting author revisions - revision_projects = projects.filter(submission_status=30) + revision_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_RESUBMISSION) # Awaiting editor copyedit - copyedit_projects = projects.filter(submission_status=40) + copyedit_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_COPYEDIT) # Awaiting author approval - approval_projects = projects.filter(submission_status=50) + approval_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_APPROVAL) # Awaiting editor publish - publish_projects = projects.filter(submission_status=60) + publish_projects = projects.filter(submission_status=SubmissionStatus.NEEDS_PUBLICATION) # Time to check if the reminder email can be sent yesterday = timezone.now() + timezone.timedelta(days=-1) @@ -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 @@ -320,7 +343,7 @@ def edit_submission(request, project_slug, *args, **kwargs): embargo_form = forms.EmbargoFilesDaysForm() # The user must be the editor - if project.submission_status not in [20, 30]: + if project.submission_status not in [SubmissionStatus.NEEDS_DECISION, SubmissionStatus.NEEDS_RESUBMISSION]: return redirect('editor_home') if request.method == 'POST': @@ -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', @@ -365,7 +389,7 @@ def copyedit_submission(request, project_slug, *args, **kwargs): Page to copyedit the submission """ project = kwargs['project'] - if project.submission_status != 40: + if project.submission_status != SubmissionStatus.NEEDS_COPYEDIT: return redirect('editor_home') copyedit_log = project.copyedit_logs.get(complete_datetime=None) @@ -551,7 +575,7 @@ def awaiting_authors(request, project_slug, *args, **kwargs): """ project = kwargs['project'] - if project.submission_status != 50: + if project.submission_status != SubmissionStatus.NEEDS_APPROVAL: return redirect('editor_home') authors, author_emails, storage_info, edit_logs, copyedit_logs, latest_version = project.info_card() @@ -613,7 +637,7 @@ def publish_submission(request, project_slug, *args, **kwargs): """ project = kwargs['project'] - if project.submission_status != 60: + if project.submission_status != SubmissionStatus.NEEDS_PUBLICATION: return redirect('editor_home') if settings.SYSTEM_MAINTENANCE_NO_UPLOAD: raise ServiceUnavailable() @@ -624,7 +648,7 @@ def publish_submission(request, project_slug, *args, **kwargs): if request.method == 'POST': publish_form = forms.PublishForm(project=project, data=request.POST) if project.is_publishable() and publish_form.is_valid(): - if project.version_order: + if project.is_new_version: slug = project.get_previous_slug() else: slug = publish_form.cleaned_data['slug'] @@ -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,23 +742,22 @@ 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 """ - projects = ActiveProject.objects.filter(submission_status=0).order_by( + projects = ActiveProject.objects.filter(submission_status=SubmissionStatus.UNSUBMITTED).order_by( '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 @@ -833,6 +923,7 @@ def manage_published_project(request, project_slug, version): contact_form = forms.PublishedProjectContactForm(project=project, instance=project.contact) legacy_author_form = forms.CreateLegacyAuthorForm(project=project) + publication_form = forms.AddPublishedPublicationForm(project=project) if request.method == 'POST': if any(x in request.POST for x in ['create_doi_core', @@ -891,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(): @@ -914,6 +1010,12 @@ def manage_published_project(request, project_slug, version): if contact_form.is_valid(): contact_form.save() messages.success(request, 'The contact information has been updated') + elif 'set_publication' in request.POST: + publication_form = forms.AddPublishedPublicationForm( + project=project, data=request.POST) + if publication_form.is_valid(): + publication_form.save() + messages.success(request, 'The associated publication has been added') elif 'set_legacy_author' in request.POST: legacy_author_form = forms.CreateLegacyAuthorForm(project=project, data=request.POST) @@ -946,24 +1048,27 @@ 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, 'legacy_author_form': legacy_author_form, - 'can_make_zip': ProjectFiles().can_make_zip(), - 'can_make_checksum': ProjectFiles().can_make_checksum(), + 'publication_form': publication_form, + 'can_make_zip': project.files.can_make_zip(), + 'can_make_checksum': project.files.can_make_checksum(), }, ) -@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 @@ -1005,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 @@ -1029,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) @@ -1038,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 @@ -1049,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 @@ -1063,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. @@ -1101,11 +1296,15 @@ def user_management(request, username): is_verified=False) projects = {} - projects['Unsubmitted'] = ActiveProject.objects.filter(authors__user=user, - submission_status=0).order_by('-creation_datetime') - projects['Submitted'] = ActiveProject.objects.filter(authors__user=user, - submission_status__gt=0).order_by('-submission_datetime') - projects['Archived'] = ArchivedProject.objects.filter(authors__user=user).order_by('-archive_datetime') + projects["Unsubmitted"] = ActiveProject.objects.filter( + authors__user=user, submission_status=SubmissionStatus.UNSUBMITTED + ).order_by("-creation_datetime") + projects["Submitted"] = ActiveProject.objects.filter( + authors__user=user, submission_status__gt=SubmissionStatus.ARCHIVED + ).order_by("-submission_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") @@ -1123,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. @@ -1159,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. @@ -1219,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. @@ -1227,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. @@ -1235,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, @@ -1376,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. @@ -1425,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. @@ -1449,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 @@ -1526,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. @@ -1583,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) @@ -1595,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. @@ -1647,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, }, ) @@ -1675,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) @@ -1751,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 @@ -1766,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) @@ -1781,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 @@ -1800,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 @@ -1822,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 @@ -1872,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 @@ -1904,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. @@ -1926,7 +2089,7 @@ def guidelines_course(request): {'guidelines_course_nav': True}) -@permission_required('project.can_view_stats', raise_exception=True) +@console_permission_required('project.can_view_stats') def editorial_stats(request): """ Editorial stats for reviewers. @@ -1965,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. @@ -1990,7 +2153,10 @@ def credentialing_stats(request): a = acc_and_rej.filter(status=CredentialApplication.Status.ACCEPTED).count() r = acc_and_rej.filter(status=CredentialApplication.Status.REJECTED).count() stats[y]['processed'] = a + r - stats[y]['approved'] = round((100 * a) / (a + r)) + try: + stats[y]['approved'] = round((100 * a) / (a + r)) + except ZeroDivisionError: + stats[y]['approved'] = None # Time taken to contact the reference time_to_ref = apps.annotate(tm=Cast(F('reference_contact_datetime') @@ -2029,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 @@ -2084,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. @@ -2149,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') @@ -2172,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) @@ -2189,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))) @@ -2216,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 = ( @@ -2245,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'] @@ -2280,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) @@ -2300,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 = ( @@ -2328,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'] @@ -2360,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))) @@ -2372,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( @@ -2386,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'] @@ -2454,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 @@ -2480,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. @@ -2492,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': @@ -2514,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) @@ -2535,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) @@ -2558,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': @@ -2568,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') @@ -2586,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) @@ -2607,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) @@ -2627,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': @@ -2637,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': @@ -2662,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': @@ -2677,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) @@ -2692,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) @@ -2715,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) @@ -2737,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) @@ -2750,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) @@ -2771,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) @@ -2791,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) @@ -2809,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) @@ -2821,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) @@ -2839,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) @@ -2862,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': @@ -2887,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) @@ -2903,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': @@ -2924,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) @@ -2944,21 +3106,35 @@ def code_of_conduct_activate(request, pk): return redirect("code_of_conduct_list") -@permission_required('user.view_all_events', raise_exception=True) -def event(request): +@console_permission_required('user.view_all_events') +def event_active(request): """ List of events """ - events = Event.objects.all() - events = paginate(request, events, 50) + event_active = Event.objects.filter(end_date__gte=timezone.now()) + event_active = paginate(request, event_active, 50) - return render(request, 'console/event.html', - {'events': events, - 'events_nav': True + return render(request, 'console/event_active.html', + {'event_active': event_active, + 'nav_event_active': True }) -@permission_required('user.view_all_events', raise_exception=True) +@console_permission_required('user.view_all_events') +def event_archive(request): + """ + List of archived events + """ + event_archive = Event.objects.filter(end_date__lte=timezone.now()) + event_archive = paginate(request, event_archive, 50) + + return render(request, 'console/event_archive.html', + {'event_archive': event_archive, + 'nav_event_archive': True + }) + + +@console_permission_required('user.view_all_events') def event_management(request, event_slug): """ Admin page for managing an individual Event. @@ -2967,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) @@ -3025,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) @@ -3055,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) @@ -3081,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 %} + 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 @@ -
    - - - - - - - - - - - - {% for participant in event.participants.all %} - - - - - - - - {% endfor %} - -
    UsernameFull nameEmailCredentialedCohost
    {{ participant.user.username }}{{ participant.user.get_full_name }}{{ participant.user.email }}{{ participant.user.get_credentialing_status }} - {% if participant.is_cohost %} - - {% else %} - - {% endif %} -
    -
    +{% 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 %}

    - + + + + Edit Event {% endif %} @@ -99,23 +102,24 @@

    {{ event.title }}


    - {% if events_active %} - {% for event in events_active %} -
{% 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 @@ @@ -70,7 +70,7 @@


{% for news in news_pieces %}

- {{ news.title }} + {{ news.title }}

{% include "notification/news_content.html" %}
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/models.py b/physionet-django/training/models.py index 6e22d3bea5..cc75b26847 100644 --- a/physionet-django/training/models.py +++ b/physionet-django/training/models.py @@ -1,146 +1,275 @@ +from token import NUMBER from django.db import models from django.utils import timezone from project.modelcomponents.fields import SafeHTMLField -from notification.utility import notify_users_of_training_expiry -from user.models import Training - -NUMBER_OF_DAYS_SET_TO_EXPIRE = 30 +from project.validators import validate_version class Course(models.Model): - training_type = models.ForeignKey('user.TrainingType', - on_delete=models.CASCADE, related_name='courses') - version = models.FloatField(default=1.0) + """ + 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',) - unique_together = ('training_type', 'version') + default_permissions = ("change",) + constraints = [ + models.UniqueConstraint( + fields=["training_type", "version"], name="unique_course" + ) + ] permissions = [ - ('can_view_course_guidelines', 'Can view course guidelines'), + ("can_view_course_guidelines", "Can view course guidelines"), ] - def update_course_for_major_version_change(self, instance): + def expire_course_version(self, instance, number_of_days): """ - If it is a major version change, it sets all former user trainings - to a reduced date, and informs them all. + This method expires the course by setting the is_active field to False and expires all + the trainings associated with it. """ - - trainings = Training.objects.filter( - training_type=instance, - process_datetime__gte=timezone.now() - instance.valid_duration) - _ = trainings.update( - process_datetime=( - timezone.now() - (instance.valid_duration - timezone.timedelta( - days=NUMBER_OF_DAYS_SET_TO_EXPIRE)))) - - for training in trainings: - notify_users_of_training_expiry( - training.user, instance.name, NUMBER_OF_DAYS_SET_TO_EXPIRE) + 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}' + 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') + course = models.ForeignKey( + "training.Course", on_delete=models.CASCADE, related_name="modules" + ) order = models.PositiveIntegerField() description = SafeHTMLField() class Meta: - unique_together = ('course', 'order') + 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') + module = models.ForeignKey( + "training.Module", on_delete=models.CASCADE, related_name="quizzes" + ) order = models.PositiveIntegerField() class ContentBlock(models.Model): - module = models.ForeignKey('training.Module', - on_delete=models.CASCADE, related_name='contents') + """ + 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): - quiz = models.ForeignKey('training.Quiz', - on_delete=models.CASCADE, related_name='choices') + """ + 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) + is_correct = models.BooleanField("Correct Choice?", default=False) class CourseProgress(models.Model): - class Status(models.TextChoices): - IN_PROGRESS = 'IP', 'In Progress' - COMPLETED = 'C', 'Completed' + """ + 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 - 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) + 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(auto_now=True) + completed_at = models.DateTimeField(null=True, blank=True) class Meta: - unique_together = ('user', 'course') + constraints = [ + models.UniqueConstraint( + fields=["user", "course"], name="unique_course_progress" + ) + ] def __str__(self): - return f'{self.user.username} - {self.course}' + 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() + 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() + 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.filter(order__gt=last_module.module.order) + .order_by("order") + .first() + ) return self.course.modules.first() class ModuleProgress(models.Model): - class Status(models.TextChoices): - IN_PROGRESS = 'IP', 'In Progress' - COMPLETED = 'C', 'Completed' + """ + Model representing the progress of a user in a module. - 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) + 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}' + return f"{self.course_progress.user.username} - {self.module}" class CompletedContent(models.Model): - module_progress = models.ForeignKey('training.ModuleProgress', on_delete=models.CASCADE, - related_name='completed_contents') - content = models.ForeignKey('training.ContentBlock', on_delete=models.CASCADE) + """ + 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}' + return f"{self.module_progress.course_progress.user.username} - {self.content}" class CompletedQuiz(models.Model): - module_progress = models.ForeignKey('training.ModuleProgress', on_delete=models.CASCADE, - related_name='completed_quizzes') - quiz = models.ForeignKey('training.Quiz', on_delete=models.CASCADE) + """ + 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}' + 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 index 641ee441a0..044fdb7b6a 100644 --- a/physionet-django/training/serializers.py +++ b/physionet-django/training/serializers.py @@ -42,15 +42,6 @@ class Meta: read_only_fields = ['id', 'course'] -class CourseSerializer(serializers.ModelSerializer): - modules = ModuleSerializer(many=True) - - class Meta: - model = Course - fields = ['version', 'modules'] - read_only_fields = ['id', 'training_type'] - - def create_quizzes(module_instance, quizzes_data): choice_bulk = [] for quiz in quizzes_data: @@ -87,37 +78,44 @@ def create_modules(course_instance, modules_data): create_contentblocks(module_instance, contents) -class TrainingTypeSerializer(serializers.ModelSerializer): - courses = CourseSerializer(many=True) +class CourseSerializer(serializers.ModelSerializer): + modules = ModuleSerializer(many=True) class Meta: - model = TrainingType - fields = ['name', 'description', 'valid_duration', 'courses'] - read_only_fields = ['id'] + 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.pop('courses')[0] + course = validated_data modules = course.pop('modules') + course['training_type'] = instance.training_type - course['training_type'] = instance course_instance = Course.objects.create(**course) create_modules(course_instance, modules) - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save() return course_instance def create(self, validated_data): with transaction.atomic(): - course = validated_data.pop('courses')[0] + course = validated_data modules = course.pop('modules') - - validated_data['required_field'] = RequiredField.PLATFORM - course['training_type'] = instance = TrainingType.objects.create(**validated_data) + 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 instance + 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 index c01f26a47a..3155d5d553 100644 --- a/physionet-django/training/templates/training/email/training_expiry_notification.html +++ b/physionet-django/training/templates/training/email/training_expiry_notification.html @@ -1,7 +1,9 @@ {% load i18n %}{% autoescape off %}{% filter wordwrap:70 %} Dear {{ name }}, -Your training {{ training }} on {{ domain }} will be expiring in {{ expiry }} days. To retain the access it provides, kindly login to your account to retake it. +Your {{ training }} training on {{ domain }} will be expiring in {{ expiry }} days. After the expiry date, you may lose +access to certain resources. To retain access, please submit a new training certificate via the training page in your +user profile. Regards diff --git a/physionet-django/training/urls.py b/physionet-django/training/urls.py index 39e5103a98..1e3749899d 100644 --- a/physionet-django/training/urls.py +++ b/physionet-django/training/urls.py @@ -3,10 +3,5 @@ urlpatterns = [ - path('settings/platform-training//', views.take_training, name='platform_training'), - path('settings/platform-training//module//', views.take_module_training, - name='platform_training_module'), - path('settings/platform-training/update-module-progress/', views.update_module_progress, - name='update_module_progress'), - path('settings/platform-training/', views.take_training, name='start_training') + ] diff --git a/physionet-django/training/views.py b/physionet-django/training/views.py index 7d79095349..d63ba9ccee 100644 --- a/physionet-django/training/views.py +++ b/physionet-django/training/views.py @@ -1,6 +1,9 @@ +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 @@ -10,6 +13,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.utils.crypto import get_random_string +from project.validators import validate_version, is_version_greater from rest_framework.parsers import JSONParser @@ -18,207 +22,62 @@ from training.models import Course, Quiz, QuizChoice, ContentBlock from training.models import CourseProgress, ModuleProgress, CompletedContent, CompletedQuiz -from training.serializers import TrainingTypeSerializer +from training.serializers import CourseSerializer +from console.views import console_permission_required -@login_required -def take_training(request, training_id=None): - - if request.method == 'POST': - if request.POST.get('training_type'): - return redirect('platform_training', request.POST['training_type']) - - return redirect("edit_training") - - course = Course.objects.prefetch_related( - Prefetch("modules__quizzes", queryset=Quiz.objects.order_by("?")), - Prefetch("modules__contents", queryset=ContentBlock.objects.all())).filter( - training_type__id=training_id).order_by('version').last() - modules = sorted(chain(course.modules.all()), key=operator.attrgetter('order')) - # get the progress of the user for the modules, updated_date - course_progress = CourseProgress.objects.filter(user=request.user, course__id=course.id).last() - if not course_progress: - course_progress = CourseProgress.objects.create(user=request.user, course_id=course.id) - - for module in modules: - module_progress = course_progress.module_progresses.filter(module_id=module.id).last() - if module_progress: - module.progress_status = module_progress.get_status_display() - module.progress_updated_date = module_progress.updated_at - else: - module.progress_status = 'Not Started' - module.progress_updated_date = None - return render(request, 'training/course.html', { - 'modules': modules, - 'course': course, - 'ModuleStatus': ModuleProgress.Status, - }) - - -@login_required -def take_module_training(request, training_id, module_id): - course = Course.objects.select_related('training_type').filter( - training_type__id=training_id).last() - module = course.modules.filter(pk=module_id).first() - - course_progress = CourseProgress.objects.filter(user=request.user, course__id=course.id).last() - if not course_progress: - course_progress = CourseProgress.objects.create(user=request.user, course_id=course.id) +@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: - # mandate the user to complete all the previous modules before starting the requested module - next_module = course_progress.get_next_module() - if next_module and next_module.id < int(module_id): - messages.error(request, 'Please complete the previous modules before starting this module.') - return redirect('platform_training', training_id) + json_file = request.FILES.get("json_file", "") - if request.method == 'POST': + if not json_file.name.endswith('.json'): + messages.warning(request, 'File is not of JSON type') + return redirect("courses") - # check if the questions are answered correctly + # Checking if the content of the JSON file is properly formatted and according to the schema try: - user_question_answers = json.loads(request.POST['question_answers']) - # convert the keys to int(javascript sends them as string) - user_question_answers = {int(k): v for k, v in user_question_answers.items()} - for question in module.quizzes.all(): - correct_answer = question.choices.filter(is_correct=True).first().id - if (question.id not in user_question_answers - or user_question_answers.get(question.id) != correct_answer): - messages.error(request, 'Please answer all questions correctly.') - return redirect('platform_training', course.training_type.id) - - except json.JSONDecodeError: - messages.error(request, 'Please submit the training correctly.') - return redirect('platform_training', course.training_type.id) - - # update the module progress - module_progress = course_progress.module_progresses.filter(module_id=module_id).last() - - if module_progress.status == ModuleProgress.Status.COMPLETED: - messages.info(request, 'You have already completed this module.') - return redirect('platform_training', course.training_type.id) - - module_progress.status = ModuleProgress.Status.COMPLETED - module_progress.save() - - # only save a training object to database in Training if it is the last module - if module == course.modules.last(): - course_progress.status = CourseProgress.Status.COMPLETED - course_progress.save() - training = Training() - slug = get_random_string(20) - while Training.objects.filter(slug=slug).exists(): - slug = get_random_string(20) - - training.slug = slug - training.training_type = course.training_type - training.user = request.user - training.process_datetime = timezone.now() - training.status = TrainingStatus.ACCEPTED - training.save() - - training_questions = [] - for question in course.training_type.questions.all(): - training_questions.append(TrainingQuestion(training=training, question=question)) + file_data = JSONParser().parse(json_file.file) + except json.decoder.JSONDecodeError: + messages.error(request, 'JSON file is not properly formatted.') + return redirect("courses") - TrainingQuestion.objects.bulk_create(training_questions) + serializer = CourseSerializer(data=file_data, partial=True) - messages.success( - request, f'Congratulations! You completed the training {course.training_type.name} successfully.') + if serializer.is_valid(raise_exception=False): + serializer.save() + messages.success(request, 'Course created successfully.') else: - messages.success( - request, f'Congratulations! You completed the module {module.name} successfully.') - return redirect('platform_training', course.training_type.id) - - return redirect('platform_training', course.training_type.id) - - course = Course.objects.prefetch_related( - Prefetch("modules__quizzes", queryset=Quiz.objects.order_by("?")), - Prefetch("modules__contents", queryset=ContentBlock.objects.all())).filter( - training_type__id=training_id).order_by('version').last() - - # we shouldn't use the next_module here as users might request to review a completed module - requested_module = course.modules.get(id=module_id) - - # find the content or quiz to be displayed - resume_content_or_quiz_module = course_progress.module_progresses.filter( - module=requested_module).last() - if not resume_content_or_quiz_module: - resume_content_or_quiz_from = 1 - else: - resume_content_or_quiz_object = resume_content_or_quiz_module.get_next_content_or_quiz() - resume_content_or_quiz_from = resume_content_or_quiz_object.order if resume_content_or_quiz_object else 1 - - # get the ids of the completed contents and quizzes - completed_contents = course_progress.module_progresses.filter( - module=requested_module).values_list('completed_contents__content_id', flat=True) - completed_contents_ids = [content for content in completed_contents if content] - - completed_quizzes = course_progress.module_progresses.filter( - module=requested_module).values_list('completed_quizzes__quiz_id', flat=True) - completed_quizzes_ids = [quiz for quiz in completed_quizzes if quiz] + messages.error(request, serializer.errors) - return render(request, 'training/quiz.html', { - 'quiz_content': sorted(chain( - requested_module.quizzes.all(), requested_module.contents.all()), - key=operator.attrgetter('order')), - 'quiz_answer': requested_module.quizzes.filter(choices__is_correct=True).values_list("id", "choices"), - 'module': requested_module, - 'course': course, - 'resume_content_or_quiz_from': resume_content_or_quiz_from, - 'completed_contents_ids': completed_contents_ids, - 'completed_quizzes_ids': completed_quizzes_ids, - }) - - -@login_required -def update_module_progress(request): - if request.method == 'POST': - course_id = request.POST.get('course_id') - module_id = request.POST.get('module_id') - update_type = request.POST.get('update_type') - update_type_id = request.POST.get('update_type_id') - - if update_type not in ['content', 'quiz']: - return JsonResponse({'detail': 'Unsupported update type'}, status=400) - - course_progress = CourseProgress.objects.filter( - user=request.user, course__id=course_id).last() - module_progress = course_progress.module_progresses.filter(module__id=module_id).last() - - with transaction.atomic(): - if not module_progress: - module_progress = ModuleProgress.objects.create( - course_progress=course_progress, - module_id=module_id) - - if update_type == 'content': - completed_content = CompletedContent.objects.create( - module_progress=module_progress, - content_id=update_type_id) - completed_content.save() - module_progress.last_completed_order = completed_content.content.order - - elif update_type == 'quiz': - completed_quiz = CompletedQuiz.objects.create( - module_progress=module_progress, - quiz_id=update_type_id) - completed_quiz.save() - module_progress.last_completed_order = completed_quiz.quiz.order - module_progress.save() - - return JsonResponse({'detail': 'success'}, status=200) + return redirect("courses") - return JsonResponse({'detail': 'Unsupported request method'}, status=400) + 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) -def courses(request): +@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: - - if request.POST.get('training_id') != "-1": - training_type = get_object_or_404(TrainingType, pk=request.POST.get('training_id')) - else: - training_type = None - + training_type = get_object_or_404(TrainingType, slug=training_slug) json_file = request.FILES.get("json_file", "") if not json_file.name.endswith('.json'): @@ -235,52 +94,84 @@ def courses(request): # Checking if the Training type with the same version already exists existing_course = Course.objects.filter(training_type=training_type) if existing_course.exists(): - if not all(map(lambda x: x.isdigit() or x == '.', str(file_data['courses'][0]['version']))): + 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.') - elif file_data['courses'][0]['version'] <= existing_course.order_by( - '-version').first().version: # Version Number is greater than the latest version - messages.error(request, 'Version number should be greater than the latest version.') - else: # Checks passed and moving to saving the course - serializer = TrainingTypeSerializer(training_type, data=file_data, partial=True) + # 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): - # A Major Version change is detected : The First digit of the version number is changed - if int(str(existing_course.order_by('-version').first().version).split('.')[0]) != int(str( - file_data['courses'][0]['version']).split('.')[0]): - # calling the update_course_for_major_version_change method to update the course - existing_course[0].update_course_for_major_version_change(training_type) serializer.save() messages.success(request, 'Course updated successfully.') - else: - serializer = TrainingTypeSerializer(training_type, 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") + return redirect("course_details", training_slug) - training_types = TrainingType.objects.filter(required_field=RequiredField.PLATFORM) + 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/index.html', + 'console/training_type/course_details.html', { - 'training_types': training_types, + '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) -def download_course(request, pk, version): - training_type = get_object_or_404(TrainingType, pk=pk) - version = float(version) +@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 = TrainingTypeSerializer(training_type) + serializer = CourseSerializer(course) response_data = serializer.data - response_data['courses'] = list(filter(lambda x: x['version'] == version, response_data['courses'])) response = JsonResponse(response_data, safe=False, json_dumps_params={'indent': 2}) response['Content-Disposition'] = f'attachment; filename={training_type.name}--version-{version}.json' return response diff --git a/physionet-django/user/fixtures/demo-training-type.json b/physionet-django/user/fixtures/demo-training-type.json index f91d28e02f..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\t
  • Defining Research with Human Subjects
  • \n\t
  • Privacy and Confidentiality
  • \n\t
  • Assessing Risk
  • \n\t
  • Research with Children
  • \n\t
  • International Research
  • \n\t
  • History and Ethical Principles
  • \n\t
  • Regulations and Process
  • \n\t
  • SBR Methodologies in Biomedical Research
  • \n\t
  • Genetics Research
  • \n\t
  • Records-Based Research
  • \n\t
  • Populations in Research Requiring Additional Considerations and/or Protections
  • \n\t
  • HIPAA and Human Subjects Research
  • \n\t
  • Conflicts 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, @@ -22,6 +23,7 @@ "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\n

What You Will Learn:

\n\n
    \n\t
  • The names and locations of the seven continents
  • \n\t
  • Key countries and their capitals on each continent
  • \n\t
  • Basic geographical features and landmarks
  • \n\t
  • Cultural and historical highlights of different regions
  • \n
\n\n

Prerequisites:

\n\n
    \n\t
  • No prior knowledge is required
  • \n\t
  • An interest in geography and world cultures is recommended
  • \n
\n\n

Don’t miss this opportunity to expand your horizons and discover the fascinating world we live in. Our experienced instructors will guide you through this engaging training, providing insights and knowledge along the way. Sign up now to reserve your spot!

\n\n

Contact: For more information or to register for this training, please contact us at training@discoveringtheworld.com or call us at 555-1234.

\n", "home_page": "/", "valid_duration": "1095 00:00:00", diff --git a/physionet-django/user/fixtures/demo-user.json b/physionet-django/user/fixtures/demo-user.json index d970a0460f..fa1eb2cb39 100644 --- a/physionet-django/user/fixtures/demo-user.json +++ b/physionet-django/user/fixtures/demo-user.json @@ -14339,25 +14339,7 @@ "fields": { "application": 109, "status": 30, - "fields_complete": null, - "appears_correct": null, - "lang_understandable": null, - "user_searchable": null, - "user_has_papers": null, - "research_summary_clear": null, - "course_name_provided": null, - "user_understands_privacy": null, - "user_org_known": null, - "user_details_consistent": null, - "ref_appropriate": null, - "ref_searchable": null, - "ref_has_papers": null, - "ref_is_supervisor": null, - "ref_course_list": null, "ref_skipped": null, - "ref_knows_applicant": null, - "ref_approves": null, - "ref_understands_privacy": null, "responder_comments": "" } }, @@ -14398,14 +14380,14 @@ "activeproject" ], [ - "change_activeproject", + "can_edit_activeprojects", "project", "activeproject" ], [ - "change_archivedproject", + "change_activeproject", "project", - "archivedproject" + "activeproject" ], [ "add_dua", @@ -14505,11 +14487,6 @@ "project", "activeproject" ], - [ - "change_archivedproject", - "project", - "archivedproject" - ], [ "can_view_access_logs", "project", diff --git a/physionet-django/user/forms.py b/physionet-django/user/forms.py index e5d144204a..b20aa39d23 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/0036_training_2.py b/physionet-django/user/migrations/0036_training_2.py index 56023894de..ee0ad6010b 100644 --- a/physionet-django/user/migrations/0036_training_2.py +++ b/physionet-django/user/migrations/0036_training_2.py @@ -5,8 +5,6 @@ from django.core.management import call_command from django.db import migrations -from project.projectfiles import ProjectFiles - def migrate_forward(apps, schema_editor): CredentialReview = apps.get_model('user', 'CredentialReview') diff --git a/physionet-django/user/migrations/0056_remove_credentialreview_appears_correct_and_more.py b/physionet-django/user/migrations/0056_remove_credentialreview_appears_correct_and_more.py new file mode 100644 index 0000000000..fbd9c02a1b --- /dev/null +++ b/physionet-django/user/migrations/0056_remove_credentialreview_appears_correct_and_more.py @@ -0,0 +1,86 @@ +# Generated by Django 4.1.9 on 2023-07-05 16:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + MIGRATE_AFTER_INSTALL = True + + dependencies = [ + ('user', '0055_auto_20230330_1723'), + ] + + operations = [ + migrations.RemoveField( + model_name='credentialreview', + name='appears_correct', + ), + migrations.RemoveField( + model_name='credentialreview', + name='course_name_provided', + ), + migrations.RemoveField( + model_name='credentialreview', + name='fields_complete', + ), + migrations.RemoveField( + model_name='credentialreview', + name='lang_understandable', + ), + migrations.RemoveField( + model_name='credentialreview', + name='ref_appropriate', + ), + migrations.RemoveField( + model_name='credentialreview', + name='ref_approves', + ), + migrations.RemoveField( + model_name='credentialreview', + name='ref_course_list', + ), + migrations.RemoveField( + model_name='credentialreview', + name='ref_has_papers', + ), + migrations.RemoveField( + model_name='credentialreview', + name='ref_is_supervisor', + ), + migrations.RemoveField( + model_name='credentialreview', + name='ref_knows_applicant', + ), + migrations.RemoveField( + model_name='credentialreview', + name='ref_searchable', + ), + migrations.RemoveField( + model_name='credentialreview', + name='ref_understands_privacy', + ), + migrations.RemoveField( + model_name='credentialreview', + name='research_summary_clear', + ), + migrations.RemoveField( + model_name='credentialreview', + name='user_details_consistent', + ), + migrations.RemoveField( + model_name='credentialreview', + name='user_has_papers', + ), + migrations.RemoveField( + model_name='credentialreview', + name='user_org_known', + ), + migrations.RemoveField( + model_name='credentialreview', + name='user_searchable', + ), + migrations.RemoveField( + model_name='credentialreview', + name='user_understands_privacy', + ), + ] diff --git a/physionet-django/user/migrations/0057_alter_cloudinformation_aws_id.py b/physionet-django/user/migrations/0057_alter_cloudinformation_aws_id.py new file mode 100644 index 0000000000..74735b2313 --- /dev/null +++ b/physionet-django/user/migrations/0057_alter_cloudinformation_aws_id.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.10 on 2023-10-04 21:14 + +from django.db import migrations, models +import user.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("user", "0056_remove_credentialreview_appears_correct_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="cloudinformation", + name="aws_id", + field=models.CharField( + blank=True, + default=None, + max_length=60, + null=True, + validators=[user.validators.validate_aws_id], + ), + ), + ] 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 0aaab5aaee..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__) @@ -1079,39 +1081,9 @@ class CredentialReview(models.Model): status = models.PositiveSmallIntegerField(default=10, choices=REVIEW_STATUS_LABELS) - # Initial review questions - # No longer checked. Consider removing these. - fields_complete = models.BooleanField(null=True) - appears_correct = models.BooleanField(null=True) - lang_understandable = models.BooleanField(null=True) - - # ID check questions - # No longer checked. Consider removing these. - user_searchable = models.BooleanField(null=True) - user_has_papers = models.BooleanField(null=True) - research_summary_clear = models.BooleanField(null=True) - course_name_provided = models.BooleanField(null=True) - user_understands_privacy = models.BooleanField(null=True) - user_org_known = models.BooleanField(null=True) - user_details_consistent = models.BooleanField(null=True) - - # Reference check questions - # No longer checked. Consider removing these. - ref_appropriate = models.BooleanField(null=True) - ref_searchable = models.BooleanField(null=True) - ref_has_papers = models.BooleanField(null=True) - ref_is_supervisor = models.BooleanField(null=True) - ref_course_list = models.BooleanField(null=True) - # Log skipped reference ref_skipped = models.BooleanField(null=True) - # Reference response check questions - # No longer checked. Consider removing these. - ref_knows_applicant = models.BooleanField(null=True) - ref_approves = models.BooleanField(null=True) - ref_understands_privacy = models.BooleanField(null=True) - # Reference response check questions responder_comments = models.CharField(max_length=500, default='', blank=True) @@ -1131,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) @@ -1147,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) @@ -1209,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 @@ -1248,7 +1264,13 @@ class CloudInformation(models.Model): on_delete=models.CASCADE) gcp_email = models.OneToOneField('user.AssociatedEmail', related_name='gcp_email', on_delete=models.SET_NULL, null=True) - aws_id = models.CharField(max_length=60, null=True, blank=True, default=None) + aws_id = models.CharField( + max_length=60, + null=True, + blank=True, + default=None, + validators=[validators.validate_aws_id], + ) class Meta: default_permissions = () diff --git a/physionet-django/user/templates/user/edit_certification.html b/physionet-django/user/templates/user/edit_certification.html new file mode 100644 index 0000000000..715a0cfebb --- /dev/null +++ b/physionet-django/user/templates/user/edit_certification.html @@ -0,0 +1,30 @@ +{% extends "user/settings.html" %} + +{% block title %}{{ SITE_NAME }} Certification{% endblock %} + +{% block main_content %} +

Certification

+
+

To gain access to certain datasets on {{ SITE_NAME }}, you are required to demonstrate that you have completed relevant training. You can find specific training requirements in the "Files" section of the project description.

+
To submit a new completion report, please go to the Training page.
+
+ + {% for status, group in training_by_status.items %} +
+

{{ status|capfirst }}

+
    + {% for course in group %} +
  • +
    + {{ course.training_type.name }} +
    + View +
  • + {% empty %} +

    N/A

    + {% endfor %} +
+
+ {% endfor %} + +{% endblock %} diff --git a/physionet-django/user/templates/user/edit_credentialing.html b/physionet-django/user/templates/user/edit_credentialing.html index 193594d307..706aceabd8 100644 --- a/physionet-django/user/templates/user/edit_credentialing.html +++ b/physionet-django/user/templates/user/edit_credentialing.html @@ -25,7 +25,7 @@

Credentialing

{% elif current_application %}

Your credentialing application was submitted on {{ current_application.application_datetime }}.

Status of your application: {{ current_application.get_review_status }}.

-

We aim to reach a decision within four weeks. If you have not received a decision within this time, it is likely that we are awaiting a response from your reference.

+

We aim to reach a decision within {{ estimated_time_for_credentialing }}. If you have not received a decision within this time, it is likely that we are awaiting a response from your reference.

{% else %}

Your account is not credentialed. diff --git a/physionet-django/user/templates/user/edit_training.html b/physionet-django/user/templates/user/edit_training.html index 3a7cc2ce55..f397848c8d 100644 --- a/physionet-django/user/templates/user/edit_training.html +++ b/physionet-django/user/templates/user/edit_training.html @@ -5,81 +5,11 @@ {% block main_content %}

Training


-

To gain access to certain datasets on {{ SITE_NAME }}, you are required to demonstrate that you have completed relevant training. You can find specific training requirements in the "Files" section of the project description.

- - {% for status, group in training_by_status.items %} - {% if not status == 'under review' %} -
-

{{ status|capfirst }}

-
    - {% for course in group %} -
  • -
    - {{ course.training_type.name }} -
    - View -
  • - {% empty %} -

    N/A

    - {% endfor %} -
-
- {% endif %} - {% endfor %} - +

To gain access to certain datasets on {{ SITE_NAME }}, you are required to demonstrate that you have completed relevant training. You can find specific training requirements in the "Files" section of the project description.

+ +
You can view the status of your training submissions on the Certification page.

-

Complete training course on Platform

- {% if take_course_form %} -
- {% include "descriptive_inline_form_snippet.html" with form=take_course_form %} - - - -
- {% else %} -

N/A

- {% endif %} - -
-

Submit evidence of a completed course

- - {% for status, group in training_by_status.items %} - {% if status == 'under review' and group %} -
-
- The following training is under review: -
    - {% for course in group %} -
  • -
    - {{ course.training_type.name }} -
    - View -
  • - {% endfor %} -
- -
- {% endif %} - {% endfor %} +

Submit evidence of a completed Training

Please use the form below to submit a new completion report.

For CITI training, please refer to our step-by-step instructions and @@ -101,7 +31,7 @@