diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..23a4847d9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (Optional):** + - OS: [e.g. macOS, Windows] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + + +**Additional context (Optional)** +Add any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..0257906b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for a feature or enhancement +title: '' +labels: feature +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered (Optional)** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context (Optional)** +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..0839f0322 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,28 @@ +## Description + +**** Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. **** + +Fixes #(issue number) + +## Type of change + + **** Please delete options that are not relevant. **** + +- Bug fix (non-breaking change which fixes an issue) +- New feature (non-breaking change which adds functionality) +- Breaking change (fix or feature that would cause existing functionality to not work as expected) + +## How Has This Been Tested? + +**** Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. **** + + +## PR Self Evaluation +Strikethrough things that don’t make sense for your PR. + +- [ ] My code follows the agreed upon best practices +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation (if needed) +- [ ] My changes generate no new warnings +- [ ] Any dependent changes have been merged and published in the appropriate modules +- [ ] I have performed a self-review of my own code diff --git a/.gitignore b/.gitignore index 32d292253..edd0de3f9 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ google-drive-key.json overrides.yml New Project Request MOUs Secure Directory Request MOUs -Service Units Purchase Request MOUs \ No newline at end of file +Service Units Purchase Request MOUs diff --git a/bootstrap/ansible/main.copyme b/bootstrap/ansible/main.copyme index df55e5305..15db4bebd 100644 --- a/bootstrap/ansible/main.copyme +++ b/bootstrap/ansible/main.copyme @@ -24,6 +24,10 @@ enable_django_debug_toolbar: false # TODO: Generate one using: `openssl rand -base64 64`. django_secret_key: +#------------------------------------------------------------------------------ +# PostgreSQL database settings +#------------------------------------------------------------------------------ + # The name of the PostgreSQL database. # TODO: For LRC, set this to 'cf_lrc_db'. db_name: cf_brc_db @@ -34,35 +38,53 @@ db_host: localhost db_admin_user: admin db_admin_passwd: '' +#------------------------------------------------------------------------------ +# Redis settings +#------------------------------------------------------------------------------ + # The password for Redis. # TODO: Replace the password. redis_passwd: '' + redis_host: localhost -# Log paths. +#------------------------------------------------------------------------------ +# Logging settings +#------------------------------------------------------------------------------ + # TODO: For LRC, use the substring 'cf_mylrc'. log_path: /var/log/user_portals/cf_mybrc portal_log_file: cf_mybrc_portal.log api_log_file: cf_mybrc_api.log + logrotate_entry_name: cf_mybrc + # TODO: Logs are only backed up if a path to an existent directory is given. log_backup_dir_path: + # TODO: For Docker environments, set this to true. stream_logs_to_stdout: false -# Apache settings. +#------------------------------------------------------------------------------ +# Apache settings +#------------------------------------------------------------------------------ + # The name of the copy of the generated WSGI template in the Apache directory. # TODO: For LRC, use the substring 'cf_mylrc'. wsgi_conf_file_name: cf_mybrc_wsgi.conf # TODO: For LRC, use the substring 'cf_lrc'. wsgi_conf_log_prefix: cf_brc -# LRC Cloudflare settings. +#------------------------------------------------------------------------------ +# LRC Cloudflare settings +#------------------------------------------------------------------------------ + # Whether the web server is behind Cloudflare. # TODO: For the LRC production deployment, enable Cloudflare, since LBL # TODO: requires that web servers visible to the Internet be placed behind it. # TODO: https://commons.lbl.gov/display/cpp/Open+Web+Server+Requirements cloudflare_enabled: false + # A list of Cloudflare's IP ranges. # TODO: Keep it up-to-date with: https://www.cloudflare.com/ips/. cloudflare_ip_ranges: [ @@ -82,41 +104,63 @@ cloudflare_ip_ranges: [ 197.234.240.0/22, 198.41.128.0/17 ] + # The name of the server, which should differ from the name of the website. # Source: See Open Web Server Requirements link above. # TODO: Set this to e.g., mylrc-local.lbl.gov for mylrc.lbl.gov if Cloudflare # TODO: is enabled. cloudflare_local_server_name: -# CILogon client settings. +#------------------------------------------------------------------------------ +# CILogon client settings +#------------------------------------------------------------------------------ + # TODO: Set these, needed only if SSO should be enabled. cilogon_app_client_id: "" cilogon_app_secret: "" -# Django Flags settings. +#------------------------------------------------------------------------------ +# Django Flags settings +#------------------------------------------------------------------------------ + +# # Note: Use uppercase True/False so that Python interprets these as booleans. + # TODO: For LRC, disable link login. flag_basic_auth_enabled: False flag_sso_enabled: True flag_link_login_enabled: True + # TODO: For LRC, disable BRC and enable LRC. flag_brc_enabled: True flag_lrc_enabled: False + # The number of the month in which users should be able to request renewal for # the next allowance year. # TODO: For LRC, set the month number to 9 (September). flag_next_period_renewal_requestable_month: 5 + +# Whether to enable UI support for users having multiple email addresses. flag_multiple_email_addresses_allowed: False + # Whether to install and enable the MOU generation package. # TODO: For BRC, enable MOU generation (requires access to the package). flag_mou_generation_enabled: False -# Portal settings. +# Whether to include a survey as part of the allowance renewal request process. +flag_renewal_survey_enabled: True + +#------------------------------------------------------------------------------ +# User-facing strings +#------------------------------------------------------------------------------ + # TODO: For LRC, use "MyLRC", "Laboratory Research Computing", "LRC", and # TODO: "Lawrencium". portal_name: "MyBRC" program_name_long: "Berkeley Research Computing" program_name_short: "BRC" + primary_cluster_name: "Savio" + # TODO: For MyLRC, use "https://it.lbl.gov/service/scienceit/high-performance-computing/lrc/". center_user_guide: "https://docs-research-it.berkeley.edu/services/high-performance-computing/user-guide/" # TODO: For MyLRC, use "https://it.lbl.gov/resource/hpc/for-users/getting-started/". @@ -124,9 +168,13 @@ center_login_guide: "https://docs-research-it.berkeley.edu/services/high-perform # TODO: For MyLRC, use "hpcshelp@lbl.gov". center_help_email: "brc-hpc-help@berkeley.edu" -# # Storage settings. -# # For more information, refer to the README. +#------------------------------------------------------------------------------ +# BRC File storage settings +#------------------------------------------------------------------------------ + +# # Backend options: 'file_system', 'google_drive' # file_storage_backend: 'file_system' + # new_project_request_mou_path: 'New Project Request MOUs/' # secure_directory_request_mou_path: 'Secure Directory Request MOUs/' # service_units_purchase_request_mou_path: 'Service Units Purchase Request MOUs/' @@ -141,40 +189,76 @@ center_help_email: "brc-hpc-help@berkeley.edu" # google_drive_private_key_file_path: '/path/to/google-drive-private-key.json' # google_drive_storage_media_root: '/' -# # MOU generator settings. -# # For more information, refer to the README. +#------------------------------------------------------------------------------ +# BRC MOU generation settings +#------------------------------------------------------------------------------ + # # TODO: For BRC deployments with access to the package for generating MOUs # # TODO: (e.g., production and staging), set the absolute path to the deploy -# # TODO: key. +# # TODO: key for the package. # mou_generator_deploy_key_path: '/path/to/id_mou_generator' -# LRC billing validation package settings (for Pip installation). +#------------------------------------------------------------------------------ +# LRC Billing validation settings +#------------------------------------------------------------------------------ + # TODO: For LRC deployments with access to the package for billing validation # TODO: (e.g., production and staging), set these. install_billing_validation_package: false # Example: "gitlab.com/user/repo_name.git" billing_validation_repo_host: "" # Create or request a deploy token. +# Reference: https://docs.gitlab.com/ee/user/project/deploy_tokens/ billing_validation_repo_username: "" billing_validation_repo_token: "" -# LRC Oracle billing database settings. -# TODO: For LRC deployments with access to Oracle, set these. +# TODO: For LRC deployments with access to the Oracle billing database, set +# TODO: these. oracle_billing_db_dsn: "" oracle_billing_db_user: "" oracle_billing_db_passwd: "" -# API settings. +#------------------------------------------------------------------------------ +# Allowance renewal survey settings +#------------------------------------------------------------------------------ + +# # Backend options: 'google_forms', 'permissive' +# renewal_survey_backend: 'google_forms' + +# # TODO: Uncomment only the section relevant to the specified renewal_survey_backend. + +# # Renewal survey settings: 'google_forms' backend. +# renewal_survey_google_forms_service_account_credentials_file_path: '/path/to/google-service-account-key.json' +# renewal_survey_google_forms_survey_data_file_path: '/path/to/google-forms-survey-data.json' +# renewal_survey_google_forms_survey_data_cache_key: 'renewal_survey_google_forms_survey_data' + +# # Renewal survey settings: 'permissive' backend. +# # N/A: No applicable settings. + +#------------------------------------------------------------------------------ +# REST API settings +#------------------------------------------------------------------------------ + # If true, bypass all checks at job submission time. allow_all_jobs: false +#------------------------------------------------------------------------------ +# Sentry settings +#------------------------------------------------------------------------------ + # The URL of the Sentry instance to send errors to. sentry_dsn: "" ############################################################################### -# staging_settings +# Deployment-specific settings: staging, production, development ############################################################################### +# TODO: Uncomment the section pertaining to the current deployment type. + +#------------------------------------------------------------------------------ +# staging_settings +#------------------------------------------------------------------------------ + # # The type of deployment ('dev', 'prod', 'staging'). # deployment: staging @@ -182,6 +266,7 @@ sentry_dsn: "" # djangooperator: root # # Whether to run the Django application in DEBUG mode. +# # Note: Use uppercase True/False so that Python interprets this as a boolean. # debug: True # # The path to the parent directory containing the Git repository. @@ -224,9 +309,9 @@ sentry_dsn: "" # # TODO: Set these addresses to yours. # request_approval_cc_list: [] -############################################################################### +#------------------------------------------------------------------------------ # prod_settings -############################################################################### +#------------------------------------------------------------------------------ # # The type of deployment ('dev', 'prod', 'staging'). # deployment: prod @@ -235,6 +320,7 @@ sentry_dsn: "" # djangooperator: meli # # Whether to run the Django application in DEBUG mode. +# # Note: Use uppercase True/False so that Python interprets this as a boolean. # debug: False # # The path to the parent directory containing the Git repository. @@ -280,9 +366,9 @@ sentry_dsn: "" # # TODO: Set these addresses to yours. # request_approval_cc_list: [] -############################################################################### +#------------------------------------------------------------------------------ # dev_settings -############################################################################### +#------------------------------------------------------------------------------ # # The type of deployment ('dev', 'prod', 'staging'). # deployment: dev @@ -291,6 +377,7 @@ sentry_dsn: "" # djangooperator: vagrant # # Whether to run the Django application in DEBUG mode. +# # Note: Use uppercase True/False so that Python interprets this as a boolean. # debug: True # # The path to the parent directory containing the Git repository. diff --git a/bootstrap/ansible/settings_template.tmpl b/bootstrap/ansible/settings_template.tmpl index 67f0bd938..35e988ba9 100644 --- a/bootstrap/ansible/settings_template.tmpl +++ b/bootstrap/ansible/settings_template.tmpl @@ -75,6 +75,23 @@ EXTRA_EXTRA_APPS = [] # Extra middleware to be included. EXTRA_EXTRA_MIDDLEWARE = [] +#------------------------------------------------------------------------------ +# Django Cache settings +#------------------------------------------------------------------------------ + +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': 'redis://{{ redis_host }}:6379/0', + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'DB': 0, + 'PASSWORD': '{{ redis_passwd }}', + }, + 'TIMEOUT': 24 * 60 * 60, # One day in seconds + } +} + #------------------------------------------------------------------------------ # File Storage Settings #------------------------------------------------------------------------------ @@ -121,6 +138,32 @@ ORACLE_BILLING_DB = { } {% endif %} +#------------------------------------------------------------------------------ +# Renewal Survey settings +#------------------------------------------------------------------------------ +{% if renewal_survey_backend == 'google_forms' %} +# Use Google Forms as the backend for renewal surveys. +RENEWAL_SURVEY = { + 'backend': 'coldfront.core.project.utils_.renewal_survey.backends.google_forms.GoogleFormsRenewalSurveyBackend', + 'details': { + # The path to a file containing credentials for the Google service + # account that has access to survey responses. + 'credentials_file_path': '{{ renewal_survey_google_forms_service_account_credentials_file_path }}', + # The path to a file on local disk that contains data about each survey. + 'survey_data_file_path': '{{ renewal_survey_google_forms_survey_data_file_path }}', + # The key to cache survey data from the above file under. + 'survey_data_cache_key': '{{ renewal_survey_google_forms_survey_data_cache_key }}', + }, +} +{% else %} +# Permit renewal requests to be made without a survey response. +RENEWAL_SURVEY = { + 'backend': 'coldfront.core.project.utils_.renewal_survey.backends.permissive.PermissiveRenewalSurveyBackend', + 'details': {}, +} + +{% endif %} + #------------------------------------------------------------------------------ # SSL settings #------------------------------------------------------------------------------ @@ -246,6 +289,7 @@ FLAGS = { 'SERVICE_UNITS_PURCHASABLE': [{'condition': 'boolean', 'value': {{ flag_brc_enabled }}}], 'SSO_ENABLED': [{'condition': 'boolean', 'value': {{ flag_sso_enabled }}}], 'MOU_GENERATION_ENABLED': [{'condition': 'boolean', 'value': {{ flag_mou_generation_enabled }}}], + 'RENEWAL_SURVEY_ENABLED': [{'condition': 'boolean', 'value': {{ flag_renewal_survey_enabled }}}], } # Enforce that boolean flags are consistent with each other. diff --git a/bootstrap/development/docker/README.md b/bootstrap/development/docker/README.md index eaf6ee622..0640a2a83 100644 --- a/bootstrap/development/docker/README.md +++ b/bootstrap/development/docker/README.md @@ -51,7 +51,7 @@ Note that these steps must be run from the root directory of the repo. ```bash export DOCKER_PROJECT_NAME=brc-dev - docker-compose \ + docker compose \ -f bootstrap/development/docker/docker-compose.yml \ -p $DOCKER_PROJECT_NAME \ up @@ -69,13 +69,20 @@ Note that these steps must be run from the root directory of the repo. Notes: - This step may be run multiple times. -8. Retrieve a PostgreSQL database dump file that will be provided for you. Place it in the root directory of the repo. Load it into your instance. You must provide the name of your Docker project. Note that this may take several minutes. +8. Retrieve a PostgreSQL database dump file that will be provided for you. Place it in the root directory of the repo. Load it into your instance. You must provide the name of your Docker project. ```bash export RELATIVE_CONTAINER_DUMP_FILE_PATH=YYYY_MM_DD-HH-MM.dump sh bootstrap/development/docker/scripts/docker_load_database_backup.sh $DOCKER_PROJECT_NAME $RELATIVE_CONTAINER_DUMP_FILE_PATH ``` + Notes: + - This may take several minutes. + - The following error may appear in the output, but is not an issue: + ``` + ERROR: role "postgres" already exists + ``` + 9. At this point, the web service should be functioning. Navigate to it from the browser at "http://localhost:WEB_PORT", where `WEB_PORT` is the one defined above. 10. After authenticating for the first time, grant your user administrator privileges in Django: @@ -83,7 +90,7 @@ Note that these steps must be run from the root directory of the repo. - Enter into the application shell container: ```bash - docker-compose -p $DOCKER_PROJECT_NAME exec app-shell bash + docker compose -p $DOCKER_PROJECT_NAME exec app-shell bash ``` - From within the container, start a Django shell: diff --git a/bootstrap/development/docker/config/docker_defaults.yml b/bootstrap/development/docker/config/docker_defaults.yml index 6adcea52d..deecfd7a0 100644 --- a/bootstrap/development/docker/config/docker_defaults.yml +++ b/bootstrap/development/docker/config/docker_defaults.yml @@ -19,3 +19,5 @@ from_email: developer@localhost admin_email: developer@localhost email_admin_list: ['developer@localhost'] request_approval_cc_list: ['developer@localhost'] + +renewal_survey_backend: 'permissive' diff --git a/bootstrap/development/docker/images/app-base.Dockerfile b/bootstrap/development/docker/images/app-base.Dockerfile index 42cbb6ea3..912c4d6ea 100644 --- a/bootstrap/development/docker/images/app-base.Dockerfile +++ b/bootstrap/development/docker/images/app-base.Dockerfile @@ -1,12 +1,13 @@ -FROM ubuntu:latest +FROM coldfront-os -RUN apt-get update && \ - apt-get install -y python3 python3-dev python3-pip && \ - apt-get install -y libfaketime && \ - # Necessary for mod-wsgi requirement - apt-get install -y apache2-dev +WORKDIR /var/www/coldfront_app/coldfront + +RUN python3 -m venv /var/www/coldfront_app/venv COPY requirements.txt . -RUN python3 -m pip install -r requirements.txt +# Pin setuptools to avoid ImportError. Source: https://stackoverflow.com/a/78387663 +RUN /var/www/coldfront_app/venv/bin/pip install --upgrade pip wheel && \ + /var/www/coldfront_app/venv/bin/pip install setuptools==68.2.2 && \ + /var/www/coldfront_app/venv/bin/pip install -r requirements.txt -WORKDIR /var/www/coldfront_app/coldfront +ENV PATH="/var/www/coldfront_app/venv/bin:$PATH" diff --git a/bootstrap/development/docker/images/app-config.Dockerfile b/bootstrap/development/docker/images/app-config.Dockerfile index e6bf94423..2b7789eea 100644 --- a/bootstrap/development/docker/images/app-config.Dockerfile +++ b/bootstrap/development/docker/images/app-config.Dockerfile @@ -1,8 +1,10 @@ -FROM ubuntu:latest +FROM coldfront-os -RUN apt-get update && \ - apt-get install -y python3 python3-dev python3-pip +WORKDIR /app -RUN pip3 install jinja2 pyyaml +RUN python3 -m venv /app/venv -WORKDIR /app +RUN /app/venv/bin/pip install --upgrade pip setuptools wheel && \ + /app/venv/bin/pip install jinja2 pyyaml + +ENV PATH="/app/venv/bin:$PATH" diff --git a/bootstrap/development/docker/images/db-postgres-shell.Dockerfile b/bootstrap/development/docker/images/db-postgres-shell.Dockerfile index 8e8879f82..3e22a5b0a 100644 --- a/bootstrap/development/docker/images/db-postgres-shell.Dockerfile +++ b/bootstrap/development/docker/images/db-postgres-shell.Dockerfile @@ -1,12 +1,9 @@ -FROM ubuntu:latest +FROM coldfront-app-base -RUN apt-get update && \ - apt-get install -y gnupg lsb-release wget +RUN dnf update -y && \ + dnf install -y gnupg wget -RUN sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \ - wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \ - apt-get update && \ - apt-get -y install postgresql-client-15 +RUN dnf module install -y postgresql:15 WORKDIR /var/www/coldfront_app/coldfront diff --git a/bootstrap/development/docker/images/os.Dockerfile b/bootstrap/development/docker/images/os.Dockerfile new file mode 100644 index 000000000..bf1e9e4ad --- /dev/null +++ b/bootstrap/development/docker/images/os.Dockerfile @@ -0,0 +1,31 @@ +FROM rockylinux/rockylinux:8.8 + +RUN dnf update -y && \ + dnf install -y \ + gcc \ + make \ + wget \ + openssl-devel \ + bzip2-devel \ + httpd-devel \ + libffi-devel \ + zlib-devel \ + readline-devel \ + redhat-rpm-config \ + sqlite-devel + +RUN mkdir -p /usr/src/python310 && \ + cd /usr/src/python310 && \ + wget https://www.python.org/ftp/python/3.10.14/Python-3.10.14.tgz && \ + tar -xzvf Python-3.10.14.tgz && \ + cd Python-3.10.14 && \ + ./configure --enable-optimizations --enable-shared && \ + make && \ + make install + +RUN rm -rf /usr/src/python310 + +RUN echo "/usr/local/lib" > /etc/ld.so.conf.d/local.conf && \ + ldconfig + +# TODO: libfaketime diff --git a/bootstrap/development/docker/scripts/build_images.sh b/bootstrap/development/docker/scripts/build_images.sh index 8c4bcccb4..6aca0e44a 100755 --- a/bootstrap/development/docker/scripts/build_images.sh +++ b/bootstrap/development/docker/scripts/build_images.sh @@ -1,5 +1,6 @@ #!/bin/bash +docker build -f bootstrap/development/docker/images/os.Dockerfile -t coldfront-os . docker build -f bootstrap/development/docker/images/app-config.Dockerfile -t coldfront-app-config bootstrap/development/docker docker build -f bootstrap/development/docker/images/app-base.Dockerfile -t coldfront-app-base . docker build -f bootstrap/development/docker/images/app-shell.Dockerfile -t coldfront-app-shell . diff --git a/bootstrap/development/docker/scripts/create_env_file.sh b/bootstrap/development/docker/scripts/create_env_file.sh index a34caac74..5f4a14221 100755 --- a/bootstrap/development/docker/scripts/create_env_file.sh +++ b/bootstrap/development/docker/scripts/create_env_file.sh @@ -24,4 +24,3 @@ fi echo "DB_NAME=$DB_NAME" > $ENV_FILE_PATH echo "WEB_PORT=$PORT" >> $ENV_FILE_PATH - diff --git a/bootstrap/development/docker/scripts/docker_generate_secrets.sh b/bootstrap/development/docker/scripts/docker_generate_secrets.sh index 231a5a933..1630f4954 100755 --- a/bootstrap/development/docker/scripts/docker_generate_secrets.sh +++ b/bootstrap/development/docker/scripts/docker_generate_secrets.sh @@ -8,8 +8,11 @@ else wd=$PWD fi +# Do not mount directly onto /app, since the venv is located there and would be +# wiped out. docker run -it \ - -v $wd/bootstrap/development/docker:/app \ + -v $wd/bootstrap/development/docker/config:/app/config \ + -v $wd/bootstrap/development/docker/scripts:/app/scripts \ + -v $wd/bootstrap/development/docker/secrets:/app/secrets \ coldfront-app-config:latest \ python3 scripts/generate_secrets.py - diff --git a/bootstrap/development/docker/scripts/docker_generate_settings.sh b/bootstrap/development/docker/scripts/docker_generate_settings.sh index 943becb55..c89f9946c 100755 --- a/bootstrap/development/docker/scripts/docker_generate_settings.sh +++ b/bootstrap/development/docker/scripts/docker_generate_settings.sh @@ -22,8 +22,11 @@ cp coldfront/config/local_strings.py.sample coldfront/config/local_strings.py cp bootstrap/ansible/main.copyme bootstrap/development/docker/config/main.yml # Re-generate the Django development settings file. +# Do not mount directly onto /app, since the venv is located there and would be +# wiped out. (docker run -it \ -v $wd/bootstrap/ansible/settings_template.tmpl:/tmp/settings_template.tmpl \ - -v $wd/bootstrap/development/docker:/app \ + -v $wd/bootstrap/development/docker/config:/app/config \ + -v $wd/bootstrap/development/docker/scripts:/app/scripts \ coldfront-app-config:latest \ python3 scripts/generate_django_settings_file.py $DEPLOYMENT) 2>/dev/null > coldfront/config/dev_settings.py diff --git a/bootstrap/development/docker/scripts/docker_load_database_backup.sh b/bootstrap/development/docker/scripts/docker_load_database_backup.sh index 6ad9d6753..3a65248be 100755 --- a/bootstrap/development/docker/scripts/docker_load_database_backup.sh +++ b/bootstrap/development/docker/scripts/docker_load_database_backup.sh @@ -4,11 +4,11 @@ PROJECT_NAME=$1 RELATIVE_CONTAINER_DUMP_FILE_PATH=$2 # TODO: There may be other services in the future. -docker-compose -p $PROJECT_NAME stop web +docker compose -p $PROJECT_NAME stop web -docker-compose \ +docker compose \ -p $PROJECT_NAME \ exec db-postgres-shell \ bash -c "bootstrap/development/docker/scripts/load_database_backup.sh $RELATIVE_CONTAINER_DUMP_FILE_PATH" -docker-compose -p $PROJECT_NAME start web +docker compose -p $PROJECT_NAME start web diff --git a/bootstrap/development/docker/scripts/docker_run_django_scripts.sh b/bootstrap/development/docker/scripts/docker_run_django_scripts.sh index 7709ff13a..fbaa776f4 100755 --- a/bootstrap/development/docker/scripts/docker_run_django_scripts.sh +++ b/bootstrap/development/docker/scripts/docker_run_django_scripts.sh @@ -2,4 +2,4 @@ PROJECT_NAME=$1 -docker-compose -p $PROJECT_NAME exec app-shell bash -c "bootstrap/development/docker/scripts/run_django_scripts.sh" +docker compose -p $PROJECT_NAME exec app-shell bash -c "bootstrap/development/docker/scripts/run_django_scripts.sh" diff --git a/coldfront/config/local_settings.py.sample b/coldfront/config/local_settings.py.sample index 9bf8fb18f..8eb6cac5f 100644 --- a/coldfront/config/local_settings.py.sample +++ b/coldfront/config/local_settings.py.sample @@ -439,8 +439,7 @@ SESAME_MAX_AGE = 300 # Data import settings #------------------------------------------------------------------------------ -# The credentials needed to read from Google Sheets. -GOOGLE_OAUTH2_KEY_FILE = "/tmp/credentials.json" + # ----------------------------------------------------------------------------- # Miscellaneous settings diff --git a/coldfront/config/test_settings.py.sample b/coldfront/config/test_settings.py.sample index 431d990f6..98b8c7677 100644 --- a/coldfront/config/test_settings.py.sample +++ b/coldfront/config/test_settings.py.sample @@ -66,6 +66,16 @@ EXTRA_EXTRA_APPS = [] # Extra middleware to be included. EXTRA_EXTRA_MIDDLEWARE = [] +#------------------------------------------------------------------------------ +# Django Cache settings +#------------------------------------------------------------------------------ + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + } +} + #------------------------------------------------------------------------------ # File Storage Settings #------------------------------------------------------------------------------ @@ -90,6 +100,16 @@ FILE_STORAGE = { }, } +#------------------------------------------------------------------------------ +# Renewal Survey settings +#------------------------------------------------------------------------------ + +# Permit renewal requests to be made without a survey response. +RENEWAL_SURVEY = { + 'backend': 'coldfront.core.project.utils_.renewal_survey.backends.permissive.PermissiveRenewalSurveyBackend', + 'details': {}, +} + #------------------------------------------------------------------------------ # SSL settings #------------------------------------------------------------------------------ @@ -128,4 +148,5 @@ FLAGS = { 'SERVICE_UNITS_PURCHASABLE': [{'condition': 'boolean', 'value': True}], 'SSO_ENABLED': [{'condition': 'boolean', 'value': False}], 'MOU_GENERATION_ENABLED': [{'condition': 'boolean', 'value': False}], + 'RENEWAL_SURVEY_ENABLED': [{'condition': 'boolean', 'value': True}], } diff --git a/coldfront/core/allocation/forms.py b/coldfront/core/allocation/forms.py index 4247e2ecb..f5f4b65d2 100644 --- a/coldfront/core/allocation/forms.py +++ b/coldfront/core/allocation/forms.py @@ -281,13 +281,16 @@ class AllocationPeriodChoiceField(forms.ModelChoiceField): def __init__(self, *args, **kwargs): self.computing_allowance = kwargs.pop('computing_allowance', None) + if (self.computing_allowance is not None and + not isinstance(self.computing_allowance, ComputingAllowance)): + self.computing_allowance = ComputingAllowance( + self.computing_allowance) self.interface = ComputingAllowanceInterface() super().__init__(*args, **kwargs) def label_from_instance(self, obj): - computing_allowance = ComputingAllowance(self.computing_allowance) num_service_units = self.allocation_value(obj) - if computing_allowance.are_service_units_prorated(): + if self.computing_allowance.are_service_units_prorated(): num_service_units = prorated_allocation_amount( num_service_units, utc_now_offset_aware(), obj) return ( @@ -297,7 +300,7 @@ def label_from_instance(self, obj): def allocation_value(self, obj): """Return the allocation value (Decimal) to use based on the allocation type and the AllocationPeriod.""" - allowance_name = self.computing_allowance.name + allowance_name = self.computing_allowance.get_name() if flag_enabled('BRC_ONLY'): assert allowance_name in self._allowances_with_periods_brc() return Decimal( diff --git a/coldfront/core/allocation/utils.py b/coldfront/core/allocation/utils.py index ca6e44ee2..4566ea253 100644 --- a/coldfront/core/allocation/utils.py +++ b/coldfront/core/allocation/utils.py @@ -237,6 +237,30 @@ def prorated_allocation_amount(amount, dt, allocation_period): return Decimal(f'{math.floor(amount):.2f}') +def calculate_service_units_to_allocate(computing_allowance, + request_time, allocation_period=None): + """Return the number of service units to allocate to a new project + request or allowance renewal request with the given + ComputingAllowance, if it were to be made at the given datetime. If + the request is associated with an AllocationPeriod, use it to + determine the number. Prorate as needed.""" + kwargs = {} + if allocation_period is not None: + kwargs['is_timed'] = True + kwargs['allocation_period'] = allocation_period + + computing_allowance_interface = ComputingAllowanceInterface() + num_service_units = Decimal( + computing_allowance_interface.service_units_from_name( + computing_allowance.get_name(), **kwargs)) + + if computing_allowance.are_service_units_prorated(): + num_service_units = prorated_allocation_amount( + num_service_units, request_time, allocation_period) + + return num_service_units + + def review_cluster_access_requests_url(): domain = settings.CENTER_BASE_URL view = reverse('allocation-cluster-account-request-list') diff --git a/coldfront/core/allocation/views_/secure_dir_views.py b/coldfront/core/allocation/views_/secure_dir_views.py index 4856284b8..7f28991dd 100644 --- a/coldfront/core/allocation/views_/secure_dir_views.py +++ b/coldfront/core/allocation/views_/secure_dir_views.py @@ -978,6 +978,9 @@ def done(self, form_list, **kwargs): @staticmethod def condition_dict(): + """Return a mapping from a string index `i` into FORMS + (zero-indexed) to a function determining whether FORMS[int(i)] + should be included.""" view = SecureDirRequestWizard return { '1': view.show_rdm_consultation_form_condition diff --git a/coldfront/core/project/forms_/new_project_forms/request_forms.py b/coldfront/core/project/forms_/new_project_forms/request_forms.py index 7029299dd..dc862c54e 100644 --- a/coldfront/core/project/forms_/new_project_forms/request_forms.py +++ b/coldfront/core/project/forms_/new_project_forms/request_forms.py @@ -51,9 +51,13 @@ class SavioProjectAllocationPeriodForm(forms.Form): def __init__(self, *args, **kwargs): computing_allowance = kwargs.pop('computing_allowance', None) super().__init__(*args, **kwargs) - display_timezone = pytz.timezone(settings.DISPLAY_TIME_ZONE) - queryset = self.allocation_period_choices( - computing_allowance, utc_now_offset_aware(), display_timezone) + if computing_allowance is not None: + computing_allowance = ComputingAllowance(computing_allowance) + display_timezone = pytz.timezone(settings.DISPLAY_TIME_ZONE) + queryset = self.allocation_period_choices( + computing_allowance, utc_now_offset_aware(), display_timezone) + else: + queryset = AllocationPeriod.objects.none() self.fields['allocation_period'] = AllocationPeriodChoiceField( computing_allowance=computing_allowance, label='Allocation Period', @@ -87,32 +91,29 @@ def allocation_period_choices(self, computing_allowance, utc_dt, def _allocation_period_choices_brc(self, computing_allowance, date, f, order_by): - """TODO""" - allowance_name = computing_allowance.name + allowance_name = computing_allowance.get_name() if allowance_name in (BRCAllowances.FCA, BRCAllowances.PCA): return self._allocation_period_choices_allowance_year( - date, f, order_by) + computing_allowance, date, f, order_by) elif allowance_name == BRCAllowances.ICA: num_days = self.NUM_DAYS_BEFORE_ICA f = f & Q(start_date__lte=date + timedelta(days=num_days)) - f = f & ( - Q(name__startswith='Fall Semester') | - Q(name__startswith='Spring Semester') | - Q(name__startswith='Summer Sessions')) + allowance_periods_q = computing_allowance.get_period_filters() + if allowance_periods_q is not None: + f = f & allowance_periods_q return AllocationPeriod.objects.filter(f).order_by(*order_by) return AllocationPeriod.objects.none() def _allocation_period_choices_lrc(self, computing_allowance, date, f, order_by): - """TODO""" - allowance_name = computing_allowance.name + allowance_name = computing_allowance.get_name() if allowance_name == LRCAllowances.PCA: return self._allocation_period_choices_allowance_year( - date, f, order_by) + computing_allowance, date, f, order_by) return AllocationPeriod.objects.none() - def _allocation_period_choices_allowance_year(self, date, f, order_by): - """TODO""" + def _allocation_period_choices_allowance_year(self, computing_allowance, + date, f, order_by): if flag_enabled('ALLOCATION_RENEWAL_FOR_NEXT_PERIOD_REQUESTABLE'): # If projects for the next period may be requested, include it. started_before_date = ( @@ -126,7 +127,9 @@ def _allocation_period_choices_allowance_year(self, date, f, order_by): # Otherwise, include only the current period. started_before_date = date f = f & Q(start_date__lte=started_before_date) - f = f & Q(name__startswith='Allowance Year') + allowance_periods_q = computing_allowance.get_period_filters() + if allowance_periods_q is not None: + f = f & allowance_periods_q return AllocationPeriod.objects.filter(f).order_by(*order_by) diff --git a/coldfront/core/project/forms_/renewal_forms/request_forms.py b/coldfront/core/project/forms_/renewal_forms/request_forms.py index f6dcba8e0..3f656fbfa 100644 --- a/coldfront/core/project/forms_/renewal_forms/request_forms.py +++ b/coldfront/core/project/forms_/renewal_forms/request_forms.py @@ -1,3 +1,5 @@ +import logging + from coldfront.core.allocation.models import AllocationPeriod from coldfront.core.allocation.models import AllocationRenewalRequest from coldfront.core.project.forms import DisabledChoicesSelectWidget @@ -12,11 +14,16 @@ from coldfront.core.project.utils_.renewal_utils import pis_with_renewal_requests_pks from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface +from coldfront.core.project.utils_.renewal_survey import is_renewal_survey_completed from flags.state import flag_enabled from django import forms from django.utils.safestring import mark_safe from django.core.validators import MinLengthValidator +from django.core.exceptions import ValidationError + + +logger = logging.getLogger(__name__) class ProjectRenewalPIChoiceField(forms.ModelChoiceField): @@ -194,7 +201,51 @@ def __init__(self, *args, **kwargs): self.fields['project'].queryset = Project.objects.filter( **_filter).exclude(**exclude).order_by('name') + class ProjectRenewalSurveyForm(forms.Form): + def __init__(self, *args, **kwargs): + self.project_name = kwargs.pop('project_name', None) + self.pi_username = kwargs.pop('pi_username', None) + self.allocation_period_name = kwargs.pop('allocation_period_name', None) + super().__init__(*args, **kwargs) + + self.fields['was_survey_completed'] = forms.BooleanField( + label=('I have completed the survey and did NOT edit the pre-filled' + ' fields at the end of the survey.'), + initial=False, + required=True, + validators=[self.validate_survey_completed] + ) + + def validate_survey_completed(self, value): + """Raise ValidationError if no renewal survey was completed for + the specified PI and Project under the specified + AllocationPeriod. + + If completion cannot be determined (e.g., due to an error in the + survey backend), do not raise an error (such that the user may + proceed). + """ + try: + is_survey_completed = is_renewal_survey_completed( + self.allocation_period_name, self.project_name, + self.pi_username) + except Exception as e: + message = ( + f'Encountered an exception when determining whether a renewal ' + f'survey was completed for {self.allocation_period_name}, ' + f'{self.project_name}, {self.pi_username}. Allowing the user ' + f'to proceed to the next step. Details:\n{e}') + logger.exception(message) + return + + if not is_survey_completed: + raise ValidationError( + f'No response for {self.pi_username} and {self.project_name} ' + f'detected for {self.allocation_period_name}.') + + +class DeprecatedProjectRenewalSurveyForm(forms.Form): def __init__(self, *args, **kwargs): disable_fields = kwargs.pop('disable_fields', False) diff --git a/coldfront/core/project/management/commands/projects.py b/coldfront/core/project/management/commands/projects.py index dd441f982..d8c9eac7b 100644 --- a/coldfront/core/project/management/commands/projects.py +++ b/coldfront/core/project/management/commands/projects.py @@ -7,9 +7,14 @@ from flags.state import flag_enabled from coldfront.core.allocation.models import Allocation +from coldfront.core.allocation.models import AllocationPeriod from coldfront.core.allocation.models import AllocationAttribute from coldfront.core.allocation.models import AllocationAttributeType from coldfront.core.allocation.models import AllocationStatusChoice +from coldfront.core.allocation.models import AllocationRenewalRequest +from coldfront.core.allocation.models import AllocationRenewalRequestStatusChoice +from coldfront.core.allocation.utils import calculate_service_units_to_allocate +from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface from coldfront.core.project.models import Project from coldfront.core.project.models import ProjectStatusChoice from coldfront.core.project.models import ProjectUser @@ -18,8 +23,12 @@ from coldfront.core.project.utils import is_primary_cluster_project from coldfront.core.project.utils_.new_project_user_utils import NewProjectUserRunnerFactory from coldfront.core.project.utils_.new_project_user_utils import NewProjectUserSource +from coldfront.core.project.utils_.renewal_utils import AllocationRenewalApprovalRunner +from coldfront.core.project.utils_.renewal_utils import AllocationRenewalProcessingRunner +from coldfront.core.project.utils_.renewal_utils import set_allocation_renewal_request_eligibility from coldfront.core.resource.models import Resource from coldfront.core.resource.utils import get_primary_compute_resource_name +from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance from coldfront.core.statistics.models import ProjectTransaction from coldfront.core.utils.common import add_argparse_dry_run_argument from coldfront.core.utils.common import display_time_zone_current_date @@ -46,11 +55,15 @@ def add_arguments(self, parser): subparsers.required = True self._add_create_subparser(subparsers) + self._add_renew_subparser(subparsers) + def handle(self, *args, **options): """Call the handler for the provided subcommand.""" subcommand = options['subcommand'] if subcommand == 'create': self._handle_create(*args, **options) + elif subcommand == 'renew': + self._handle_renew(*args, **options) @staticmethod def _add_create_subparser(parsers): @@ -82,6 +95,32 @@ def _add_create_subparser(parsers): type=str) add_argparse_dry_run_argument(parser) + @staticmethod + def _add_renew_subparser(parsers): + """Add a subparser for the 'renew' subcommand.""" + parser = parsers.add_parser( + 'renew', + help='Renew a PI\'s allowance under a project.') + parser.add_argument( + 'name', help='The name of the project to renew under.', type=str) + parser.add_argument( + 'allocation_period', + help='The name of the AllocationPeriod to renew under.', + type=str) + parser.add_argument( + 'pi_username', + help=( + 'The username of the user whose allowance should be renewed. ' + 'The PI must be an active PI of the project.'), + type=str) + parser.add_argument( + 'requester_username', + help=( + 'The username of the user making the request. The requester ' + 'must be an active manager or PI of the project.'), + type=str) + add_argparse_dry_run_argument(parser) + @staticmethod def _create_project_with_compute_allocation_and_pis(project_name, compute_resource, @@ -180,6 +219,103 @@ def _handle_create(self, *args, **options): self.stdout.write(self.style.SUCCESS(message)) self.logger.info(message) + @staticmethod + def _renew_project(project, allocation_period, requester, pi, + computing_allowance, num_service_units): + """Renew the computing allowance of the given PI under the given + project for the given AllocationPeriod, as requested by the + given User and granting the given number of service units. + + 1. Create an AllocationRenewalRequest. + 2. Update the state of the request to prepare it for + approval. + 3. Approve the request. + 4. Process the request. + + Assumptions: + - The ComputingAllowance is renewable. + - (TODO: Temporary) The ComputingAllowance is not one per + PI. + - The PI is an active PI of the project. + - The requester is an active Manager or PI of the project. + - The allowance is being renewed under the same project + (i.e., there is no change in pooling preferences). + - The AllocationPeriod is current: it has started, and it + has not ended. + """ + pre_project = project + post_project = project + request_time = utc_now_offset_aware() + status = AllocationRenewalRequestStatusChoice.objects.get( + name='Under Review') + + with transaction.atomic(): + request = AllocationRenewalRequest.objects.create( + requester=requester, + pi=pi, + computing_allowance=computing_allowance.get_resource(), + allocation_period=allocation_period, + status=status, + pre_project=pre_project, + post_project=post_project, + request_time=request_time) + + eligibility_status = 'Approved' + eligibility_justification = '' + set_allocation_renewal_request_eligibility( + request, eligibility_status, eligibility_justification) + + # TODO: The command currently assumes that the period has already + # started. If allowing renewals for future periods: + # - Refactor and reuse existing logic for determining whether to + # run the processing runner. + # - Refactor ane reuse existing logic to filter out periods that + # are too far in the future. + + approval_runner = AllocationRenewalApprovalRunner( + request, num_service_units, email_strategy=DropEmailStrategy()) + approval_runner.run() + + request.refresh_from_db() + processing_runner = AllocationRenewalProcessingRunner( + request, num_service_units) + processing_runner.run() + + def _handle_renew(self, *args, **options): + """Handle the 'renew' subcommand.""" + cleaned_options = self._validate_renew_options(options) + + project_name = options['name'] + alloc_period_name = options['allocation_period'] + requester_str = options['requester_username'] + pi_str = options['pi_username'] + + message_template = ( + f'{{0}} the allocation for PI "{pi_str}" under Project ' + f'"{project_name}" for {alloc_period_name}, ' + f'requested by {requester_str}.') + if options['dry_run']: + message = message_template.format('Would renew') + self.stdout.write(self.style.WARNING(message)) + return + + try: + self._renew_project( + cleaned_options['project'], + cleaned_options['allocation_period'], + cleaned_options['requester'], + cleaned_options['pi'], + cleaned_options['computing_allowance'], + cleaned_options['num_service_units']) + except Exception as e: + message = message_template.format('Failed to renew') + self.stderr.write(self.style.ERROR(message)) + self.logger.exception(f'{message}\n{e}') + else: + message = message_template.format('Renewed') + self.stdout.write(self.style.SUCCESS(message)) + self.logger.info(message) + @staticmethod def _validate_create_options(options): """Validate the options provided to the 'create' subcommand. @@ -254,3 +390,124 @@ def _validate_create_options(options): 'compute_resource': compute_resource, 'pi_users': pi_users, } + + @staticmethod + def _validate_renew_options(options): + """Validate the options provided to the 'renew' subcommand. + Raise a subcommand if any are invalid or if they violate + business logic, else return a dict of the form: + { + 'project': Project, + 'requester': User, + 'pi': User, + 'allocation_period': AllocationPeriod, + 'computing_allowance': ComputingAllowance, + 'num_service_units': Decimal, + } + """ + project_name = options['name'].lower() + try: + project = Project.objects.get(name=project_name) + except Project.DoesNotExist: + raise CommandError( + f'A Project with name "{project_name}" does not exist.') + + computing_allowance_interface = ComputingAllowanceInterface() + computing_allowance = ComputingAllowance( + computing_allowance_interface.allowance_from_project(project)) + if not computing_allowance.is_renewable(): + raise CommandError( + f'Computing allowance "{computing_allowance.get_name()}" is ' + f'not renewable.') + + # TODO: There are some allowances which a PI may only have one of. + # Disallow renewals of these until business logic (in progress) is in + # place to enforce this constraint. + if computing_allowance.is_one_per_pi(): + raise CommandError( + 'Renewals of computing allowances that are limited per PI are ' + 'not currently supported by this command.') + + active_project_user_status = ProjectUserStatusChoice.objects.get( + name='Active') + manager_project_user_role = ProjectUserRoleChoice.objects.get( + name='Manager') + pi_project_user_role = ProjectUserRoleChoice.objects.get( + name='Principal Investigator') + + # The requester must be an "Active" "Manager" or "Principal + # Investigator" of the project. + requester_username = options['requester_username'] + try: + requester = User.objects.get(username=requester_username) + except User.DoesNotExist: + raise CommandError( + f'User with username "{requester_username}" does not exist.') + is_requester_valid = ProjectUser.objects.filter( + project=project, user=requester, + role__in=[manager_project_user_role, pi_project_user_role], + status=active_project_user_status).exists() + if not is_requester_valid: + raise CommandError( + f'Requester {requester.username} is not an active member of ' + f'the project "{project_name}".') + + # The PI must be an "Active" "Principal Investigator" of the project. + pi_username = options['pi_username'] + try: + pi = User.objects.get(username=pi_username) + except User.DoesNotExist: + raise CommandError( + f'User with username "{pi_username}" does not exist.') + is_pi_valid = ProjectUser.objects.filter( + project=project, user=pi, role=pi_project_user_role, + status=active_project_user_status).exists() + if not is_pi_valid: + raise CommandError( + f'{pi} is not an active PI of the project "{project_name}".') + + # The AllocationPeriod must be: + # (a) valid for the project's computing allowance, and + # (b) current. + allocation_period_name = options['allocation_period'] + try: + allocation_period = AllocationPeriod.objects.get( + name=allocation_period_name) + except AllocationPeriod.DoesNotExist: + raise CommandError( + f'AllocationPeriod "{allocation_period_name}" does not exist.') + + allowance_periods_q = computing_allowance.get_period_filters() + if not allowance_periods_q: + raise CommandError( + f'Unexpectedly found no AllocationPeriod filters for ' + f'"{computing_allowance.get_name()}".') + allocation_periods_for_allowance = AllocationPeriod.objects.filter( + allowance_periods_q) + try: + error = ( + f'"{allocation_period_name}" is not a valid AllocationPeriod ' + f'for computing allowance "{computing_allowance.get_name()}".') + assert allocation_period in allocation_periods_for_allowance, error + except AssertionError as e: + raise CommandError(e) + + try: + allocation_period.assert_started() + allocation_period.assert_not_ended() + except AssertionError as e: + raise CommandError(e) + + request_time = utc_now_offset_aware() + num_service_units = calculate_service_units_to_allocate( + computing_allowance, request_time, + allocation_period=allocation_period) + + return { + 'project': project, + 'requester': requester, + 'pi': pi, + 'allocation_period': allocation_period, + 'computing_allowance': computing_allowance, + 'num_service_units': num_service_units, + } diff --git a/coldfront/core/project/templates/project/project_renewal/project_renewal_survey.html b/coldfront/core/project/templates/project/project_renewal/project_renewal_survey.html index 2c798c88f..9f51b74e8 100644 --- a/coldfront/core/project/templates/project/project_renewal/project_renewal_survey.html +++ b/coldfront/core/project/templates/project/project_renewal/project_renewal_survey.html @@ -30,7 +30,29 @@
Please respond to the following questions to provide us with more information about your project and computational needs.
+ ++ Please fill out the survey below to provide us with more information about your project and computational needs. + + At the end of the survey, there are pre-filled fields needed for administrator purposes. Please do not edit them. + + If you edit them, your survey response may not be detected. +
+ +{% if renewal_survey_url %} + + Go to Survey + +{% else %} ++ Survey unavailable. Please proceed to the next step. +
+{% endif %} + +