diff --git a/.github/workflows/django_testing_ci.yml b/.github/workflows/django_testing_ci.yml index a173340c8..cc2e33cce 100644 --- a/.github/workflows/django_testing_ci.yml +++ b/.github/workflows/django_testing_ci.yml @@ -19,7 +19,7 @@ jobs: services: postgres: # Set up a database container with the following specifications - image: postgres:9.6.24-alpine3.15 + image: postgres:15.2-alpine3.17 env: POSTGRES_DB: cf_brc_db POSTGRES_PASSWORD: test diff --git a/README.md b/README.md index 397354eca..3433d8423 100644 --- a/README.md +++ b/README.md @@ -118,14 +118,6 @@ which can be done with: - Any custom Django settings can be applied by modifying `dev_settings.py`. Note that running the Ansible playbook will overwrite these. -- It may be convenient to add the following to `/home/vagrant/.bashrc`: - ``` - # Upon login, navigate to the ColdFront directory and source the virtual environment. - cd /vagrant/coldfront_app/coldfront - source /vagrant/coldfront_app/venv/bin/activate - # Restart Apache with a keyword. - alias reload="sudo service httpd restart" - ``` #### Emails diff --git a/Vagrantfile b/Vagrantfile index f8e2845d8..8d6880ea3 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -14,9 +14,9 @@ Vagrant.configure("2") do |config| config.vm.provision "ansible_local" do |ansible| ansible.playbook = "coldfront_app/coldfront/bootstrap/ansible/playbook.yml" ansible.galaxy_role_file = "coldfront_app/coldfront/bootstrap/ansible/requirements.yml" - ansible.galaxy_roles_path = "/home/vagrant/.ansible" + ansible.galaxy_roles_path = "/home/vagrant/.ansible/roles" # https://github.com/hashicorp/vagrant/issues/10958#issuecomment-724431455 - ansible.galaxy_command = "ansible-galaxy collection install --requirements-file %{role_file} --collections-path %{roles_path}/collections --force && ansible-galaxy install --role-file=%{role_file} --roles-path=%{roles_path} --force" + ansible.galaxy_command = "ansible-galaxy collection install --requirements-file %{role_file} --collections-path /home/vagrant/.ansible/collections --force && ansible-galaxy install --role-file=%{role_file} --roles-path=%{roles_path} --force" end config.vm.network :forwarded_port, host: 8880, guest: 80 diff --git a/bootstrap/ansible/playbook.yml b/bootstrap/ansible/playbook.yml index 38adc5594..56e8791f8 100644 --- a/bootstrap/ansible/playbook.yml +++ b/bootstrap/ansible/playbook.yml @@ -1,7 +1,7 @@ --- - name: MyBRC/LRC User Portal VM Playbook - gather_facts: false + gather_facts: true hosts: - all - "{{ domain }}" @@ -14,6 +14,11 @@ provisioning_tasks: true common_tasks: true + python_version: 3.6 + postgres_version: 15 + python_version_dotless: "{{ python_version | regex_replace('\\.','') }}" + postgres_version_dotless: "{{ postgres_version | regex_replace('\\.','') }}" + vars_files: - "../../main.yml" @@ -25,7 +30,7 @@ service: name: NetworkManager state: stopped - enabled: no + enabled: false - name: Disable SELinux ansible.posix.selinux: @@ -34,14 +39,10 @@ # Install yum packages. - - name: Install curl - yum: - name: curl - state: present - - - name: Install required OS packages + - name: Install required packages yum: name: + - curl - texinfo - ntp - zlib-devel @@ -52,8 +53,13 @@ - openssl-devel - git-core - gcc-c++ + - libffi-devel state: present + - name: Install SCL + include_role: + role: smbambling.scl + - name: Install required Python packages yum: name: @@ -72,23 +78,31 @@ - mod_ssl state: present - - name: Install redis + # Install and configure Redis. + + - name: Install Redis yum: - name: - - redis + name: redis state: present - - name: Set redis password + - name: Set Redis password lineinfile: path: /etc/redis.conf regexp: '^requirepass ' insertafter: '^# requirepass foobared' line: 'requirepass {{ redis_passwd }}' + - name: Restart and enable Redis + service: + name: redis + state: restarted + enabled: true + + # Install and configure Supervisord. + - name: Install Supervisor yum: - name: - - supervisor + name: supervisor state: present # ini_file is included in community.general. @@ -113,56 +127,45 @@ option: 'numprocs' value: '1' + - name: Restart and enable Supervisord + service: + name: supervisord + state: restarted + enabled: true + + # Install and configure PostgreSQL. + + - name: Add Postgres yum repository + yum_repository: + name: postgres-repository + description: postgres repository + baseurl: https://download.postgresql.org/pub/repos/yum/{{ postgres_version }}/redhat/rhel-7-x86_64/ + gpgkey: http://yum.postgresql.org/RPM-GPG-KEY-PGDG-94 + - name: Install Postgresql packages yum: name: - - 'https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/postgresql96-9.6.24-1PGDG.rhel7.x86_64.rpm' - - 'https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/postgresql96-libs-9.6.24-1PGDG.rhel7.x86_64.rpm' - - 'https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/postgresql96-devel-9.6.24-1PGDG.rhel7.x86_64.rpm' - - 'https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/postgresql96-test-9.6.24-1PGDG.rhel7.x86_64.rpm' - - 'https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/postgresql96-server-9.6.24-1PGDG.rhel7.x86_64.rpm' + - 'postgresql{{ postgres_version }}' + - 'postgresql{{ postgres_version }}-libs' + - 'postgresql{{ postgres_version }}-devel' + - 'postgresql{{ postgres_version }}-test' + - 'postgresql{{ postgres_version }}-server' state: present - # Configure PostgreSQL. - - name: Check if PostgreSQL is initialized stat: - path: /var/lib/pgsql/9.6/data/PG_VERSION + path: /var/lib/pgsql/{{ postgres_version }}/data/PG_VERSION register: pgdata_dir_version - name: Initialize PostgreSQL if not already initialized - command: /usr/pgsql-9.6/bin/postgresql96-setup initdb + command: /usr/pgsql-{{ postgres_version }}/bin/postgresql-{{ postgres_version_dotless }}-setup initdb when: not pgdata_dir_version.stat.exists - name: Start and enable PostgreSQL service service: - name: postgresql-9.6 + name: postgresql-{{ postgres_version }} state: started - enabled: yes - - - name: Update pg_hba.conf to change host auth from ident to md5 (1/2) - postgresql_pg_hba: - dest: /var/lib/pgsql/9.6/data/pg_hba.conf - backup: yes - method: md5 - contype: host - source: ::1 - - - name: Update pg_hba.conf to change host auth from ident to md5 (2/2) - postgresql_pg_hba: - dest: /var/lib/pgsql/9.6/data/pg_hba.conf - backup: yes - method: md5 - contype: host - source: 127.0.0.1/32 - - - name: Update pg_hba.conf to set authentication for all to md5 - postgresql_pg_hba: - dest: /var/lib/pgsql/9.6/data/pg_hba.conf - backup: yes - method: md5 - contype: host - source: "0.0.0.0/0" + enabled: true # Create Django application log files. @@ -177,7 +180,7 @@ - name: Create a log file for Django portal logs copy: content: "" - force: no + force: false dest: "{{ log_path }}/{{ portal_log_file }}" mode: 0660 owner: "{{ djangooperator }}" @@ -186,7 +189,7 @@ - name: Create a log file for Django API logs copy: content: "" - force: no + force: false dest: "{{ log_path }}/{{ api_log_file }}" mode: 0660 owner: "{{ djangooperator }}" @@ -205,11 +208,17 @@ - name: Initialize Python virtual environment shell: > test -f {{ git_prefix }}/venv/bin/activate - || (python3.6 -m venv {{ git_prefix }}/venv + || (python{{ python_version }} -m venv {{ git_prefix }}/venv && echo created) register: venv_register become_user: "{{ djangooperator }}" changed_when: venv_register.stdout == "created" + + - name: Upgrade pip + pip: + name: pip + executable: "{{ git_prefix }}/venv/bin/pip3" + state: latest # Install Django application dependencies, including psycopg2, # required to create the PostgreSQL database and user, and mod-wsgi, @@ -220,6 +229,8 @@ requirements: "{{ git_prefix }}/{{ reponame }}/requirements.txt" executable: "{{ git_prefix }}/venv/bin/pip3" become_user: "{{ djangooperator }}" + environment: # needed as pg_config isn't in PATH by default + PATH: "{{ ansible_env.PATH }}:/usr/pgsql-{{ postgres_version }}/bin" # TODO: If this raises an error, the token is exposed in stderr. - name: Install the Python package for validating LBL billing IDs @@ -229,34 +240,33 @@ when: install_billing_validation_package no_log: true - # Create a PostgreSQL database and a user for the Django application. + # Create a PostgreSQL user and database for the Django application. - - name: Create a new PostgreSQL database - postgresql_db: - name: "{{ db_name }}" - become_user: postgres - vars: - ansible_python_interpreter: "{{ git_prefix }}/venv/bin/python3" - - - name: Create a PostgreSQL admin user and grant permissions + - name: Create a PostgreSQL admin user postgresql_user: name: "{{ db_admin_user }}" - db: "{{ db_name }}" - priv: ALL password: "{{ db_admin_passwd }}" role_attr_flags: CREATEDB become_user: postgres vars: ansible_python_interpreter: "{{ git_prefix }}/venv/bin/python3" + - name: Create a new PostgreSQL database under the admin user + postgresql_db: + name: "{{ db_name }}" + owner: "{{ db_admin_user }}" + become_user: postgres + vars: + ansible_python_interpreter: "{{ git_prefix }}/venv/bin/python3" + # Configure Apache. - - name: Check if the mod_wsgi module from Python 3.6 is installed to Apache + - name: Check if the mod_wsgi module from Python {{ python_version }} is installed to Apache stat: path: /etc/httpd/conf.modules.d/02-wsgi.conf register: mod_wsgi_installed - - name: Install the mod_wsgi module from Python 3.6 to Apache + - name: Install the mod_wsgi module from Python {{ python_version }} to Apache shell: "{{ git_prefix }}/venv/bin/mod_wsgi-express install-module --modules-directory /etc/httpd/modules > /etc/httpd/conf.modules.d/02-wsgi.conf" when: not mod_wsgi_installed.stat.exists @@ -292,7 +302,7 @@ service: name: rsyslog state: restarted - enabled: yes + enabled: true # Configure the firewall. @@ -300,14 +310,14 @@ service: name: firewalld state: started - enabled: yes + enabled: true - name: Permit http traffic in public zone ansible.posix.firewalld: zone: public service: http state: enabled - permanent: yes + permanent: true - name: Run Cloudflare Tasks block: @@ -318,20 +328,20 @@ zone: public service: https state: disabled - permanent: yes + permanent: true - name: Deny 443/tcp in public zone ansible.posix.firewalld: zone: public port: 443/tcp state: disabled - permanent: yes + permanent: true - name: Create firewalld zone for Cloudflare IP ranges ansible.posix.firewalld: zone: cloudflare state: present - permanent: yes + permanent: true # Firewalld must be reloaded after zone transactions. # https://docs.ansible.com/ansible/latest/collections/ansible/posix/firewalld_module.html#notes @@ -344,7 +354,7 @@ ansible.posix.firewalld: zone: cloudflare source: "{{ item }}" - permanent: yes + permanent: true state: enabled loop: "{{ cloudflare_ip_ranges }}" @@ -353,7 +363,7 @@ zone: cloudflare service: "{{ item }}" state: enabled - permanent: yes + permanent: true loop: - http - https @@ -390,10 +400,23 @@ # Development only provisioning tasks. - name: Run Development Provisioning Tasks block: - # Don't do anything as there aren't any development-specific tasks yet. - - meta: noop - # Uncomment when tasks are added. - # when: provisioning_tasks == true and deployment == "dev" + + - name: Install vim + yum: + name: vim + state: present + + - name: Add dev QOL lines to .bashrc + blockinfile: + path: /home/{{ djangooperator }}/.bashrc + block: | + # Upon login, navigate to the ColdFront directory and source the virtual environment. + cd {{ git_prefix }}/{{ reponame }} + source ../venv/bin/activate + # Restart Apache with a keyword. + alias reload="sudo service httpd reload" + + when: provisioning_tasks == true and deployment == "dev" tags: provisioning # Non-provisioning tasks common to development and production. @@ -437,25 +460,26 @@ src: "{{ git_prefix }}/{{ reponame }}/bootstrap/ansible/password_reset_subject_template.tmpl" dest: "{{ git_prefix }}/{{ reponame }}/{{ djangoprojname }}/core/user/templates/user/passwords/password_reset_subject.txt" - # Restart Services. + # Ensure services are updated, running, and enabled. Avoid restarting + # them to avoid disruptions to operations. - - name: Restart and enable Redis service + - name: Ensure that Redis service is running and enabled service: name: redis - state: restarted - enabled: yes + state: started + enabled: true - - name: Restart and enable Supervisor service + - name: Ensure that Supervisord service is running and enabled service: name: supervisord - state: restarted - enabled: yes + state: started + enabled: true - - name: Restart and enable PostgreSQL service + - name: Ensure that PostgreSQL service is reloaded and enabled service: - name: postgresql-9.6 - state: restarted - enabled: yes + name: postgresql-{{ postgres_version }} + state: reloaded + enabled: true # Install Django application dependencies @@ -552,14 +576,17 @@ file: path: "{{ git_prefix }}/{{ reponame }}" state: directory - recurse: yes + recurse: true mode: "u=rwX,g=rX,o=rX" group: apache + # Gracefully restart Apache so that processes handle current requests + # before being replaced by a new process. - name: Reload httpd service service: name: httpd state: reloaded + enabled: true # supervisorctl is included in community.general. - name: Ensure that a Supervisor worker is running a django_q cluster diff --git a/bootstrap/ansible/requirements.yml b/bootstrap/ansible/requirements.yml index 92a0244ce..378225d7f 100644 --- a/bootstrap/ansible/requirements.yml +++ b/bootstrap/ansible/requirements.yml @@ -1,4 +1,6 @@ --- +roles: + - name: smbambling.scl collections: - name: ansible.posix - name: community.general diff --git a/bootstrap/development/load_database_backup.sh b/bootstrap/development/load_database_backup.sh index dc2fd3d9c..ef1a43dab 100644 --- a/bootstrap/development/load_database_backup.sh +++ b/bootstrap/development/load_database_backup.sh @@ -6,9 +6,10 @@ # Store second last and last arguments. DB_NAME=${@:(-2):1} +DB_OWNER=admin DUMP_FILE=${@: -1} -BIN=/usr/pgsql-9.6/bin/pg_restore +BIN=/usr/pgsql-15/bin/pg_restore if [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]] ; then echo "Usage: `basename $0` [OPTION] name-of-database absolute-path-to-dump-file" @@ -30,10 +31,11 @@ if [[ "$1" == "-k" || "$1" == "--kill-connections" ]] ; then fi sudo -u postgres /bin/bash -c "echo DROPPING DATABASE... \ - && psql -c 'DROP DATABASE $DB_NAME;'; \ - \ - echo RECREATING DATABASE... \ - && psql -c 'CREATE DATABASE $DB_NAME;' \ - \ - && echo LOADING DATABASE... \ - && $BIN -d $DB_NAME $DUMP_FILE" + && psql -c 'DROP DATABASE $DB_NAME;'; \ + \ + echo RECREATING DATABASE... \ + && psql -c 'CREATE DATABASE $DB_NAME OWNER $DB_OWNER;' \ + \ + && echo LOADING DATABASE... \ + && $BIN -d $DB_NAME $DUMP_FILE; \ + psql -c 'ALTER SCHEMA public OWNER TO $DB_OWNER;' $DB_NAME;" diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 8b6165993..26a3939b0 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -1100,7 +1100,6 @@ def post(self, request, *args, **kwargs): class AllocationRequestListView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): template_name = 'allocation/allocation_request_list.html' - login_url = '/' def test_func(self): """ UserPassesTestMixin Tests""" @@ -1121,7 +1120,6 @@ def get_context_data(self, **kwargs): class AllocationActivateRequestView(LoginRequiredMixin, UserPassesTestMixin, View): - login_url = '/' def test_func(self): """ UserPassesTestMixin Tests""" @@ -1186,7 +1184,6 @@ def get(self, request, pk): class AllocationDenyRequestView(LoginRequiredMixin, UserPassesTestMixin, View): - login_url = '/' def test_func(self): """ UserPassesTestMixin Tests""" diff --git a/coldfront/core/allocation/views_/cluster_access_views.py b/coldfront/core/allocation/views_/cluster_access_views.py index 27d8c4306..48412c5f3 100644 --- a/coldfront/core/allocation/views_/cluster_access_views.py +++ b/coldfront/core/allocation/views_/cluster_access_views.py @@ -128,7 +128,6 @@ class AllocationClusterAccountRequestListView(LoginRequiredMixin, UserPassesTestMixin, ListView): template_name = 'allocation/allocation_cluster_account_request_list.html' - login_url = '/' completed = False paginate_by = 30 context_object_name = "cluster_request_list" @@ -261,7 +260,6 @@ class AllocationClusterAccountUpdateStatusView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = AllocationClusterAccountUpdateStatusForm - login_url = '/' template_name = ( 'allocation/allocation_update_cluster_account_status.html') @@ -330,7 +328,6 @@ class AllocationClusterAccountActivateRequestView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = AllocationClusterAccountRequestActivationForm - login_url = '/' template_name = ( 'allocation/allocation_activate_cluster_account_request.html') @@ -428,7 +425,6 @@ def get_success_url(self): class AllocationClusterAccountDenyRequestView(LoginRequiredMixin, UserPassesTestMixin, View): - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" diff --git a/coldfront/core/allocation/views_/secure_dir_views.py b/coldfront/core/allocation/views_/secure_dir_views.py index 10682246c..f53e07374 100644 --- a/coldfront/core/allocation/views_/secure_dir_views.py +++ b/coldfront/core/allocation/views_/secure_dir_views.py @@ -782,7 +782,6 @@ class SecureDirRequestLandingView(LoginRequiredMixin, template_name = \ 'secure_dir/secure_dir_request/secure_dir_request_landing.html' - login_url = '/' def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -1067,7 +1066,6 @@ class SecureDirRequestListView(LoginRequiredMixin, TemplateView): template_name = 'secure_dir/secure_dir_request/secure_dir_request_list.html' - login_url = '/' # Show completed requests if True; else, show pending requests. completed = False @@ -1166,7 +1164,6 @@ class SecureDirRequestDetailView(LoginRequiredMixin, model = SecureDirRequest template_name = \ 'secure_dir/secure_dir_request/secure_dir_request_detail.html' - login_url = '/' context_object_name = 'secure_dir_request' logger = logging.getLogger(__name__) @@ -1353,7 +1350,6 @@ class SecureDirRequestReviewRDMConsultView(LoginRequiredMixin, form_class = SecureDirRDMConsultationReviewForm template_name = ( 'secure_dir/secure_dir_request/secure_dir_consult_rdm.html') - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -1425,7 +1421,6 @@ class SecureDirRequestReviewMOUView(LoginRequiredMixin, form_class = ReviewStatusForm template_name = ( 'secure_dir/secure_dir_request/secure_dir_mou.html') - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -1493,7 +1488,6 @@ class SecureDirRequestReviewSetupView(LoginRequiredMixin, form_class = SecureDirSetupForm template_name = ( 'secure_dir/secure_dir_request/secure_dir_setup.html') - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -1568,7 +1562,6 @@ class SecureDirRequestReviewDenyView(LoginRequiredMixin, UserPassesTestMixin, form_class = ReviewDenyForm template_name = ( 'secure_dir/secure_dir_request/secure_dir_review_deny.html') - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -1631,7 +1624,6 @@ class SecureDirRequestUndenyRequestView(LoginRequiredMixin, UserPassesTestMixin, SecureDirRequestMixin, View): - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" diff --git a/coldfront/core/billing/tests/test_utils/test_validation_backends.py b/coldfront/core/billing/tests/test_utils/test_validation_backends.py new file mode 100644 index 000000000..291afc971 --- /dev/null +++ b/coldfront/core/billing/tests/test_utils/test_validation_backends.py @@ -0,0 +1,68 @@ +from coldfront.core.billing.tests.test_billing_base import TestBillingBase +from coldfront.core.billing.utils.validation.backends.dummy import DummyValidatorBackend +from coldfront.core.billing.utils.validation.backends.permissive import PermissiveValidatorBackend + + +class TestValidatorBackendBase(TestBillingBase): + """A base class for testing billing validator backends.""" + + def setUp(self): + """Set up test data.""" + pass + + def assert_valid(self, validator, billing_id, negate=False): + """Assert that the given billing ID is valid, according to the + given validator. If negate is True, assert that it is + invalid.""" + func = self.assertTrue if not negate else self.assertFalse + func(validator.is_billing_id_valid(billing_id)) + + +class TestDummyValidatorBackend(TestValidatorBackendBase): + """A class for testing the DummyValidatorBackend class.""" + + def setUp(self): + """Set up test data.""" + self.validator = DummyValidatorBackend() + + def test_evens_valid(self): + """Test that the validator treats billing IDs whose last digit + is even as valid.""" + for billing_id in ('123456-788', '123456-790', '123456-792'): + self.assert_valid(self.validator, billing_id) + + def test_odds_invalid(self): + """Test that the validator treats billing IDs whose last digit + is odd as invalid.""" + for billing_id in ('123456-789', '123456-791', '123456-793'): + self.assert_valid(self.validator, billing_id, negate=True) + + +class TestPermissiveValidatorBackend(TestValidatorBackendBase): + """A class for testing the PermissiveValidatorBackend class.""" + + def setUp(self): + """Set up test data.""" + self.validator = PermissiveValidatorBackend() + + def test_all_valid(self): + """Test that the validator treats all billing IDs as valid.""" + billing_ids = ( + '123456-788', + '123456-789', + '123456-790', + '123456-791', + '123456-792', + '123456-793', + ) + for billing_id in billing_ids: + self.assert_valid(self.validator, billing_id) + + +class TestOracleValidatorBackend(TestValidatorBackendBase): + """A class for testing the OracleValidatorBackend class.""" + + # TODO + # Note: The backend is not generally accessible, and validity for a + # particular billing ID may change over time. + pass diff --git a/coldfront/core/billing/utils/validation/backends/permissive.py b/coldfront/core/billing/utils/validation/backends/permissive.py new file mode 100644 index 000000000..eb348b134 --- /dev/null +++ b/coldfront/core/billing/utils/validation/backends/permissive.py @@ -0,0 +1,9 @@ +from coldfront.core.billing.utils.validation.backends.base import BaseValidatorBackend + + +class PermissiveValidatorBackend(BaseValidatorBackend): + """A backend that treats all billing IDs as valid.""" + + def is_billing_id_valid(self, billing_id): + """Return True.""" + return True diff --git a/coldfront/core/grant/views.py b/coldfront/core/grant/views.py index 17a95e684..8e8c34578 100644 --- a/coldfront/core/grant/views.py +++ b/coldfront/core/grant/views.py @@ -198,7 +198,6 @@ def test_func(self): class GrantDownloadView(LoginRequiredMixin, UserPassesTestMixin, View): - login_url = "/" def test_func(self): """ UserPassesTestMixin Tests""" diff --git a/coldfront/core/project/templates/project/project_detail.html b/coldfront/core/project/templates/project/project_detail.html index ac50576e4..64c40ba58 100644 --- a/coldfront/core/project/templates/project/project_detail.html +++ b/coldfront/core/project/templates/project/project_detail.html @@ -131,12 +131,6 @@

Requests to Join: User requests to join the project must be approved by a PI. - {% if is_allowed_to_update_project %} - - Update Project settings - - - {% endif %}

diff --git a/coldfront/core/project/templates/project/project_request/savio/project_request_list.html b/coldfront/core/project/templates/project/project_request/savio/project_request_list.html index 1d9a78faa..cc8f8797a 100644 --- a/coldfront/core/project/templates/project/project_request/savio/project_request_list.html +++ b/coldfront/core/project/templates/project/project_request/savio/project_request_list.html @@ -5,7 +5,7 @@ {% block title %} -Savio Project {{ request_filter|title }} Requests +{{ PRIMARY_CLUSTER_NAME }} Project {{ request_filter|title }} Requests {% endblock %} diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 6f1cc08e5..09a78d50c 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -1210,7 +1210,6 @@ def project_update_email_notification(request): class ProjectReviewView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): template_name = 'project/project_review.html' - login_url = "/" # redirect URL if fail test_func def test_func(self): """ UserPassesTestMixin Tests""" @@ -1320,7 +1319,6 @@ def test_func(self): class ProjectReviewCompleteView(LoginRequiredMixin, UserPassesTestMixin, View): - login_url = "/" def test_func(self): """ UserPassesTestMixin Tests""" @@ -1354,7 +1352,6 @@ def get(self, request, project_review_pk): class ProjectReivewEmailView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = ProjectReviewEmailForm template_name = 'project/project_review_email.html' - login_url = "/" def test_func(self): """ UserPassesTestMixin Tests""" diff --git a/coldfront/core/project/views_/addition_views/approval_views.py b/coldfront/core/project/views_/addition_views/approval_views.py index 34310db01..8eaf4da98 100644 --- a/coldfront/core/project/views_/addition_views/approval_views.py +++ b/coldfront/core/project/views_/addition_views/approval_views.py @@ -40,7 +40,6 @@ class AllocationAdditionRequestDetailView(LoginRequiredMixin, model = AllocationAdditionRequest template_name = 'project/project_allocation_addition/request_detail.html' - login_url = '/' context_object_name = 'addition_request' request_obj = None @@ -196,7 +195,6 @@ class AllocationAdditionRequestListView(LoginRequiredMixin, Service Units under Projects.""" template_name = 'project/project_allocation_addition/request_list.html' - login_url = '/' completed = False def get_context_data(self, **kwargs): @@ -271,7 +269,6 @@ class AllocationAdditionReviewBase(LoginRequiredMixin, UserPassesTestMixin, """A base class for views for reviewing an AllocationAdditionRequest.""" - login_url = '/' error_message = 'Unexpected failure. Please contact an administrator.' request_obj = None diff --git a/coldfront/core/project/views_/addition_views/request_views.py b/coldfront/core/project/views_/addition_views/request_views.py index 6aacbdf11..49b4ac25e 100644 --- a/coldfront/core/project/views_/addition_views/request_views.py +++ b/coldfront/core/project/views_/addition_views/request_views.py @@ -44,7 +44,6 @@ class AllocationAdditionRequestLandingView(LoginRequiredMixin, Recharge.""" template_name = 'project/project_allocation_addition/request_landing.html' - login_url = '/' project_obj = None @@ -131,7 +130,6 @@ class AllocationAdditionRequestView(LoginRequiredMixin, UserPassesTestMixin, form_class = SavioProjectRechargeExtraFieldsForm template_name = 'project/project_allocation_addition/request_form.html' - login_url = '/' project_obj = None diff --git a/coldfront/core/project/views_/join_views/request_views.py b/coldfront/core/project/views_/join_views/request_views.py index 9f058e04b..c99c2f71c 100644 --- a/coldfront/core/project/views_/join_views/request_views.py +++ b/coldfront/core/project/views_/join_views/request_views.py @@ -35,7 +35,6 @@ class ProjectJoinView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - login_url = '/' logger = logging.getLogger(__name__) diff --git a/coldfront/core/project/views_/new_project_views/approval_views.py b/coldfront/core/project/views_/new_project_views/approval_views.py index d46e688d0..9d42339bb 100644 --- a/coldfront/core/project/views_/new_project_views/approval_views.py +++ b/coldfront/core/project/views_/new_project_views/approval_views.py @@ -56,7 +56,6 @@ class SavioProjectRequestListView(LoginRequiredMixin, TemplateView): template_name = 'project/project_request/savio/project_request_list.html' - login_url = '/' # Show completed requests if True; else, show pending requests. completed = False @@ -196,7 +195,6 @@ class SavioProjectRequestDetailView(LoginRequiredMixin, UserPassesTestMixin, SavioProjectRequestMixin, DetailView): model = SavioProjectAllocationRequest template_name = 'project/project_request/savio/project_request_detail.html' - login_url = '/' logger = logging.getLogger(__name__) @@ -484,7 +482,6 @@ class SavioProjectReviewEligibilityView(LoginRequiredMixin, form_class = ReviewStatusForm template_name = ( 'project/project_request/savio/project_review_eligibility.html') - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -552,7 +549,6 @@ class SavioProjectReviewReadinessView(LoginRequiredMixin, UserPassesTestMixin, form_class = ReviewStatusForm template_name = ( 'project/project_request/savio/project_review_readiness.html') - login_url = '/' logger = logging.getLogger(__name__) @@ -630,7 +626,6 @@ class SavioProjectReviewMemorandumSignedView(LoginRequiredMixin, form_class = MemorandumSignedForm template_name = ( 'project/project_request/savio/project_review_memorandum_signed.html') - login_url = '/' logger = logging.getLogger(__name__) @@ -699,7 +694,6 @@ class SavioProjectReviewSetupView(LoginRequiredMixin, UserPassesTestMixin, SavioProjectRequestMixin, FormView): form_class = SavioProjectReviewSetupForm template_name = 'project/project_request/savio/project_review_setup.html' - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -785,7 +779,6 @@ class SavioProjectReviewDenyView(LoginRequiredMixin, UserPassesTestMixin, form_class = ReviewDenyForm template_name = ( 'project/project_request/savio/project_review_deny.html') - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -844,7 +837,6 @@ def get_success_url(self): class SavioProjectUndenyRequestView(LoginRequiredMixin, UserPassesTestMixin, SavioProjectRequestMixin, View): - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -905,7 +897,6 @@ def get(self, request, *args, **kwargs): class VectorProjectRequestListView(LoginRequiredMixin, TemplateView): template_name = 'project/project_request/vector/project_request_list.html' - login_url = '/' # Show completed requests if True; else, show pending requests. completed = False @@ -995,7 +986,6 @@ class VectorProjectRequestDetailView(LoginRequiredMixin, UserPassesTestMixin, model = VectorProjectAllocationRequest template_name = ( 'project/project_request/vector/project_request_detail.html') - login_url = '/' context_object_name = 'vector_request' logger = logging.getLogger(__name__) @@ -1125,7 +1115,6 @@ class VectorProjectReviewEligibilityView(LoginRequiredMixin, form_class = ReviewStatusForm template_name = ( 'project/project_request/vector/project_review_eligibility.html') - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -1190,7 +1179,6 @@ class VectorProjectReviewSetupView(LoginRequiredMixin, UserPassesTestMixin, VectorProjectRequestMixin, FormView): form_class = VectorProjectReviewSetupForm template_name = 'project/project_request/vector/project_review_setup.html' - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -1272,7 +1260,6 @@ def get_success_url(self): class VectorProjectUndenyRequestView(LoginRequiredMixin, UserPassesTestMixin, VectorProjectRequestMixin, View): - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" diff --git a/coldfront/core/project/views_/new_project_views/request_views.py b/coldfront/core/project/views_/new_project_views/request_views.py index c292996fd..d38fdd7bd 100644 --- a/coldfront/core/project/views_/new_project_views/request_views.py +++ b/coldfront/core/project/views_/new_project_views/request_views.py @@ -645,7 +645,6 @@ class VectorProjectRequestView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = VectorProjectDetailsForm template_name = 'project/project_request/vector/project_details.html' - login_url = '/' logger = logging.getLogger(__name__) diff --git a/coldfront/core/project/views_/removal_views.py b/coldfront/core/project/views_/removal_views.py index 6bc61b68d..79ad39184 100644 --- a/coldfront/core/project/views_/removal_views.py +++ b/coldfront/core/project/views_/removal_views.py @@ -52,7 +52,7 @@ def test_func(self): def dispatch(self, request, *args, **kwargs): project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - if project_obj.status.name not in ['Active', 'New', ]: + if project_obj.status.name not in ['Active', 'Inactive', 'New', ]: messages.error( request, 'You cannot remove users from an archived project.') return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) @@ -203,7 +203,6 @@ class ProjectRemovalRequestListView(LoginRequiredMixin, UserPassesTestMixin, ListView): template_name = 'project/project_removal/project_removal_request_list.html' - login_url = '/' completed = False paginate_by = 30 context_object_name = "project_removal_request_list" @@ -345,7 +344,6 @@ def get_context_data(self, **kwargs): class ProjectRemovalRequestUpdateStatusView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = ProjectRemovalRequestUpdateStatusForm - login_url = '/' template_name = \ 'project/project_removal/project_removal_request_update_status.html' @@ -408,7 +406,6 @@ class ProjectRemovalRequestCompleteStatusView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = ProjectRemovalRequestCompletionForm - login_url = '/' template_name = \ 'project/project_removal/project_removal_request_complete_status.html' diff --git a/coldfront/core/project/views_/renewal_views/approval_views.py b/coldfront/core/project/views_/renewal_views/approval_views.py index 316465c9f..c58418efc 100644 --- a/coldfront/core/project/views_/renewal_views/approval_views.py +++ b/coldfront/core/project/views_/renewal_views/approval_views.py @@ -43,7 +43,6 @@ class AllocationRenewalRequestListView(LoginRequiredMixin, TemplateView): template_name = 'project/project_renewal/project_renewal_request_list.html' - login_url = '/' completed = False def get_queryset(self): @@ -111,9 +110,11 @@ def get_service_units_to_allocate(self): num_service_units = Decimal( ComputingAllowanceInterface().service_units_from_name( self.computing_allowance_obj.get_name())) - return prorated_allocation_amount( - num_service_units, self.request_obj.request_time, - self.allocation_period_obj) + if self.computing_allowance_obj.are_service_units_prorated(): + num_service_units = prorated_allocation_amount( + num_service_units, self.request_obj.request_time, + self.allocation_period_obj) + return num_service_units def set_common_context_data(self, context): """Given a dictionary of context variables to include in the @@ -138,7 +139,6 @@ class AllocationRenewalRequestDetailView(LoginRequiredMixin, model = AllocationRenewalRequest template_name = ( 'project/project_renewal/project_renewal_request_detail.html') - login_url = '/' error_message = 'Unexpected failure. Please contact an administrator.' request_obj = None @@ -335,7 +335,6 @@ class AllocationRenewalRequestReviewEligibilityView(LoginRequiredMixin, FormView): form_class = ReviewStatusForm template_name = 'project/project_renewal/review_eligibility.html' - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -411,7 +410,6 @@ class AllocationRenewalRequestReviewDenyView(LoginRequiredMixin, FormView): form_class = ReviewDenyForm template_name = 'project/project_renewal/review_deny.html' - login_url = '/' def test_func(self): """UserPassesTestMixin tests.""" @@ -488,7 +486,6 @@ def get_success_url(self): # UserPassesTestMixin, # AllocationRenewalRequestMixin, # View): -# login_url = '/' # # def test_func(self): # """UserPassesTestMixin tests.""" diff --git a/coldfront/core/resource/utils_/allowance_utils/computing_allowance.py b/coldfront/core/resource/utils_/allowance_utils/computing_allowance.py index 51b88d8b7..08868dc83 100644 --- a/coldfront/core/resource/utils_/allowance_utils/computing_allowance.py +++ b/coldfront/core/resource/utils_/allowance_utils/computing_allowance.py @@ -43,6 +43,15 @@ def has_infinite_service_units(self): allowance_names.append(LRCAllowances.LR) allowance_names.append(LRCAllowances.RECHARGE) return self._name in allowance_names + + def is_condo(self): + """Return whether the allowance is a condo allocation.""" + allowance_names = [] + if flag_enabled('BRC_ONLY'): + allowance_names.append(BRCAllowances.CO) + if flag_enabled('LRC_ONLY'): + allowance_names.append(LRCAllowances.LR) + return self._name in allowance_names def is_instructional(self): """Return whether the allowance is for a course.""" diff --git a/coldfront/core/socialaccount/adapter.py b/coldfront/core/socialaccount/adapter.py index ce9f9d3eb..3f1c5c200 100644 --- a/coldfront/core/socialaccount/adapter.py +++ b/coldfront/core/socialaccount/adapter.py @@ -35,8 +35,8 @@ def populate_user(self, request, sociallogin, data): user_uid = 'unknown' if provider == 'cilogon': - first_name = data.get('first_name') - last_name = data.get('last_name') + first_name = data.get('first_name', '') + last_name = data.get('last_name', '') email = data.get('email') validated_email = valid_email_or_none(email) diff --git a/coldfront/core/statistics/views.py b/coldfront/core/statistics/views.py index 91a46775f..948fb0ee7 100644 --- a/coldfront/core/statistics/views.py +++ b/coldfront/core/statistics/views.py @@ -29,7 +29,6 @@ class SlurmJobListView(LoginRequiredMixin, ListView): template_name = 'job_list.html' - login_url = '/' paginate_by = 30 context_object_name = 'job_list' diff --git a/coldfront/core/user/templates/user/user_profile.html b/coldfront/core/user/templates/user/user_profile.html index 82090b203..7b660ba1c 100644 --- a/coldfront/core/user/templates/user/user_profile.html +++ b/coldfront/core/user/templates/user/user_profile.html @@ -298,22 +298,24 @@

Request Linking Email for OTP Management -
- {% if not has_cluster_access or linking_request and linking_request.status.name == 'Pending' %} - - - Request Linking Email - - {% else %} -
- {% csrf_token %} - - - {% endif %} -
+ + {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% endif %}

diff --git a/coldfront/core/user/utils_/host_user_utils.py b/coldfront/core/user/utils_/host_user_utils.py index 358685365..ba0ae51ac 100644 --- a/coldfront/core/user/utils_/host_user_utils.py +++ b/coldfront/core/user/utils_/host_user_utils.py @@ -1,6 +1,6 @@ from django.contrib.auth.models import User -from coldfront.core.user.models import EmailAddress +from allauth.account.models import EmailAddress def eligible_host_project_users(project): @@ -35,7 +35,7 @@ def lbl_email_address(user): if user.email.endswith(email_domain): return user.email email_addresses = EmailAddress.objects.filter( - user=user, is_verified=True, email__endswith=email_domain).order_by( + user=user, verified=True, email__endswith=email_domain).order_by( 'email') if not email_addresses.exists(): return None diff --git a/coldfront/core/user/views.py b/coldfront/core/user/views.py index cdd7cad9a..cbbe59801 100644 --- a/coldfront/core/user/views.py +++ b/coldfront/core/user/views.py @@ -24,6 +24,8 @@ from django.views.generic import CreateView, ListView, TemplateView from django.views.generic.edit import FormView +from allauth.account.models import EmailAddress as EmailAddress_LRC + from coldfront.core.allocation.utils import has_cluster_access from coldfront.core.project.models import Project, ProjectUser from coldfront.core.user.models import IdentityLinkingRequest, IdentityLinkingRequestStatusChoice @@ -426,14 +428,26 @@ def get_queryset(self): users = users.filter(username__icontains=data.get('username')) if data.get('email'): - _users = EmailAddress.objects.filter(is_primary=False, email__icontains=data.get('email'))\ - .order_by('user').values_list('user__id') - users = users.filter(Q(email__icontains=data.get('email')) | Q(id__in=_users)) + users = self._filter_users_by_email(users, data) else: users = User.objects.all().order_by(order_by) return users.distinct() + @staticmethod + def _filter_users_by_email(users, data): + if flag_enabled('LRC_ONLY'): + _users = EmailAddress_LRC.objects.filter(primary=False, email__icontains=data.get('email'))\ + .order_by('user').values_list('user__id') + users = users.filter( + Q(email__icontains=data.get('email')) | Q(id__in=_users)) + else: + _users = EmailAddress.objects.filter(is_primary=False, email__icontains=data.get('email'))\ + .order_by('user').values_list('user__id') + users = users.filter( + Q(email__icontains=data.get('email')) | Q(id__in=_users)) + return users + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) user_count = self.get_queryset().count() @@ -546,28 +560,46 @@ def form_valid(self, form): class UserLoginView(View): """Redirect to the Basic Auth. login view or the SSO login view - based on enabled flags.""" + based on enabled flags, retaining any provided next URL. If the user + is authenticated, redirect to the next URL or the home page.""" def dispatch(self, request, *args, **kwargs): - basic_auth_enabled = 'BASIC_AUTH_ENABLED' - if flag_enabled(basic_auth_enabled): - return redirect(reverse('basic-auth-login')) - sso_enabled = 'SSO_ENABLED' - if flag_enabled(sso_enabled): - return redirect(reverse('sso-login')) - raise ImproperlyConfigured( - f'One of the following flags must be enabled: ' - f'{basic_auth_enabled}, {sso_enabled}.') + next_url = request.GET.get('next') + + if request.user.is_authenticated: + return redirect(next_url or reverse('home')) + + basic_auth_enabled = flag_enabled('BASIC_AUTH_ENABLED') + sso_enabled = flag_enabled('SSO_ENABLED') + if not basic_auth_enabled ^ sso_enabled: + raise ImproperlyConfigured( + 'One of the following flags must be enabled: ' + 'BASIC_AUTH_ENABLED, SSO_ENABLED.') + if basic_auth_enabled: + redirect_url = reverse('basic-auth-login') + else: + redirect_url = reverse('sso-login') + + if next_url: + redirect_url = self._url_with_next(redirect_url, next_url) + return redirect(redirect_url) + + @staticmethod + def _url_with_next(url, next_url): + """Return the given URL, with a next parameter set to the given + next URL.""" + return f'{url}?next={next_url}' class SSOLoginView(TemplateView): """Display the template for SSO login. If the user is authenticated, - redirect to the home page.""" + redirect to the provided next URL or the home page.""" template_name = 'user/sso_login.html' def dispatch(self, request, *args, **kwargs): if request.user.is_authenticated: - return redirect(reverse('home')) + redirect_url = request.GET.get('next') or reverse('home') + return redirect(redirect_url) return super().dispatch(request, *args, **kwargs) @@ -847,7 +879,6 @@ class UpdatePrimaryEmailAddressView(LoginRequiredMixin, FormView): form_class = PrimaryEmailAddressSelectionForm template_name = 'user/user_update_primary_email_address.html' - login_url = '/' error_message = 'Unexpected failure. Please contact an administrator.' @@ -928,7 +959,6 @@ def get(self, request, *args, **kwargs): @method_decorator(login_required, name='dispatch') class IdentityLinkingRequestView(UserPassesTestMixin, View): - login_url = '/' pending_status = None def test_func(self): diff --git a/coldfront/core/utils/management/commands/audit_data.py b/coldfront/core/utils/management/commands/audit_data.py new file mode 100644 index 000000000..2d2176d43 --- /dev/null +++ b/coldfront/core/utils/management/commands/audit_data.py @@ -0,0 +1,318 @@ +''' +Add a command audit_data to ensure that certain invariants hold. These include, +but are not limited to: + - Allocations have the expected start_date and end_date values. + - Inactive Projects have "Expired" Allocations and zero SUs. + - Each Project has at least one PI. + - Users with a cluster UID should be associated with at least one Project. +TODO: Lawrencium features don't work and need to be further developed. +LRC only: + - Each UserProfile with access to Lawrencium should have a billing_activity. + - Each Recharge Allocation that has access to Lawrencium should have a + "Billing Activity"-typed AllocationAttribute. + - Each Recharge AllocationUser that has access to Lawrencium should have a + "Billing Activity"-typed AllocationUserAttribute. + - From Enforce that LRC PIs are LBL employees #392, each PI of a Project on + Lawrencium must be an LBL employee. + +''' + +from flags.state import flag_enabled + +from django.core.management.base import BaseCommand +from django.db.models import Q + +from coldfront.core.allocation.models import Allocation, \ + AllocationPeriod, \ + AllocationUser +from coldfront.core.project.models import Project, \ + ProjectUser +from coldfront.core.user.models import UserProfile +from coldfront.core.allocation.models import AllocationAttribute + +from coldfront.core.project.utils_.renewal_utils import \ + get_current_allowance_year_period +from coldfront.core.resource.utils_.allowance_utils.computing_allowance import \ + ComputingAllowance +from coldfront.core.utils.common import display_time_zone_current_date + +class Command(BaseCommand): + help = 'Audit data to ensure that certain invariants hold.' + + def add_arguments(self, parser): + parser.add_argument('--all', action='store_true', + help='Run all non-LRC-only checks') + parser.add_argument('--allocation-date', action='store_true', + help='Check that allocations have the expected start_date and ' + 'end_date values') + parser.add_argument('--project-inactive', action='store_true', + help='Check that inactive projects have "Expired" allocations and ' + 'zero SUs') + parser.add_argument('--project-pi', action='store_true', + help='Check that projects have at least one PI') + parser.add_argument('--user-project', action='store_true', + help='Check that users with a cluster UID should be associated ' + 'with at least one Project') + + parser.add_argument('--lrc-user-billing', action='store_true', + help='Check that LRC users have a billing_activity') + parser.add_argument('--lrc-recharge-allocation-billing', + action='store_true', + help='Check that LRC Recharge Allocations have a ' + '"Billing Activity"-typed AllocationAttribute') + parser.add_argument('--lrc-recharge-allocation-user-billing', + action='store_true', + help='Check that LRC Recharge AllocationUsers have a ' + '"Billing Activity"-typed AllocationUserAttribute') + parser.add_argument('--lrc-pis-lbl-employee', action='store_true', + help='Check that LRC PIs are LBL employees') + + + def handle(self, *args, **options): + if options['all']: + options['allocation_date'] = True + options['project_inactive'] = True + options['project_pi'] = True + options['user_project'] = True + # TODO: Uncomment when Lawrencium features are ready. + # options['lrc_all'] = flag_enabled('LRC_ONLY') + if options.get('lrc_all', False): + options['lrc_user_billing'] = True + options['lrc_recharge_allocation_billing'] = True + options['lrc_recharge_allocation_user_billing'] = True + options['lrc_pis_lbl_employee'] = True + for option in options: + if isinstance(options[option], bool) and options[option] and \ + option not in ('all', 'lrc_all'): + self.stdout.write(self.style.SUCCESS(f'Running {option}...')) + getattr(self, f'handle_{option}')() + self.stdout.write(self.style.SUCCESS('Audit complete.') + '\n\n') + + def handle_allocation_date(self): + ''' + Assert that all compute allocations have the expected start_date + and end_date values. + - Allocation end date is after the start date + - FCAs and PCAs: + FCAs and PCAs are valid within a so-called “Allowance Year” + (June 1st - May 31st for BRC, October 1st - September 30th for LRC): + - Inactive ones (ones that were not renewed) should have a start + date set to the start of the current allowance year + AllocationPeriod, but no end date. + - Active ones (ones that were renewed, or were created during + this allowance year) should have a start before and an end date + equal to that of the current allowance year AllocationPeriod. + ICAs (Instructional Computing Allowances) are valid within a + particular UC Berkeley semester. + - Inactive ones should have a start date, but no end date. + - Active ones (currently none on production) should match a current + (has started, but has not ended) instructional AllocationPeriod. + Recharge and Condo may/should have start dates, but don’t have end + dates, as they don’t end. + ''' + allocations = Allocation.objects.select_related('project', + 'project__status').prefetch_related('resources') \ + .filter(resources__name__endswith='Compute') \ + .order_by('resources__name', 'project__name', + 'project__status__name', 'start_date', 'end_date') + YEARLY_ALLOCATION_PERIOD = get_current_allowance_year_period() + INSTRUCTIONAL_ALLOCATION_PERIODS = AllocationPeriod.objects.filter( + Q(end_date__gt=display_time_zone_current_date()) + & (Q(name__startswith='Fall Semester') + | Q(name__startswith='Spring Semester') + | Q(name__startswith='Summer Sessions') \ + )).all() + + for allocation in allocations: + id = allocation.id + start_date = allocation.start_date + end_date = allocation.end_date + project_status = allocation.project.status.name + project = allocation.project.name + try: + resource = allocation.resources.first() + resource_name = resource.name + except AttributeError as e: + self.stdout.write(self.style.ERROR(f'Allocation {id} has no ' + 'resource')) + continue + + if end_date is not None and end_date < start_date: + self.stdout.write(self.style.ERROR(f'{resource_name} {id} ' + f'for {project_status.lower()} project {project} has an end ' + f'date before its start date.')) + + if ComputingAllowance(resource).is_yearly(): + if start_date < YEARLY_ALLOCATION_PERIOD.start_date: + self.stdout.write(self.style.ERROR(f'{resource_name} {id} ' + f'for {project_status.lower()} FCA or PCA project {project}' + f' has a start date of {start_date} that is before its ' + f'allocation period\'s ' + f'({YEARLY_ALLOCATION_PERIOD.start_date} for ' + f'{YEARLY_ALLOCATION_PERIOD}).')) + if project_status == 'Active' and \ + end_date != YEARLY_ALLOCATION_PERIOD.end_date: + self.stdout.write(self.style.ERROR(f'{resource_name} {id} ' + f'for active FCA or PCA project {project} has an end ' + f'date of {end_date} that is different than ' + f'its allocation period\'s ' + f'({YEARLY_ALLOCATION_PERIOD.end_date} for ' + f'{YEARLY_ALLOCATION_PERIOD}).')) + + elif ComputingAllowance(resource).is_instructional(): + if project_status == 'Inactive' and end_date is not None: + self.stdout.write(self.style.ERROR(f'{resource_name} {id} ' + f'for inactive ICA project {project} has an end date ' + f'(it shouldn\'t as it\'s inactive).')) + if project_status == 'Active' \ + and not any(end_date != ica.end_date \ + for ica in INSTRUCTIONAL_ALLOCATION_PERIODS): + self.stdout.write(self.style.ERROR(f'{resource_name} {id} ' + f'for {project_status.lower()} ICA project ' + f'{project} has an end date that is different from all ICA ' + f'allocation periods.')) + # Condo or Recharge + elif ComputingAllowance(resource).is_recharge() or \ + ComputingAllowance(resource).is_condo(): + if end_date is not None: + self.stdout.write(self.style.ERROR(f'{resource_name} {id} ' + f'for {project_status.lower()} Recharge or Condo ' + f'allocation project {project} has an end date. ' + f'(it shouldn\'t)')) + + def handle_project_inactive(self): + ''' + Assert that inactive Projects have "Expired" allocations and zero SUs. + ''' + inactive_projects = Project.objects \ + .prefetch_related('allocation_set__status', + 'allocation_set__resources', + 'allocation_set__allocationattribute_set') \ + .filter(status__name='Inactive') \ + .order_by('name') + + for project in inactive_projects: + for allocation in project.allocation_set \ + .select_related('status') \ + .prefetch_related('allocationattribute_set', + 'resources'): + try: + resource = allocation.resources.first().name + style = (lambda x: + self.style.WARNING('CURRENTLY EXPECTED BEHAVIOR: ') \ + + self.style.WARNING(x)) if \ + resource.endswith('Directory') \ + else self.style.ERROR + if allocation.status.name != 'Expired': + self.stdout.write(style(f'Project {project.name} is ' + f'inactive and has an unexpired ' + f'{resource} allocation {allocation.id}.')) + except AttributeError as e: + pass + try: + allocation_attribute = allocation.allocationattribute_set \ + .get(allocation_attribute_type__name='Service Units') + if float(allocation_attribute.value) != 0: + self.stdout.write(self.style.ERROR( + f'Project {project.name} is inactive and has ' + f'non-zero SUs.')) + except AllocationAttribute.DoesNotExist as e: + pass + + def handle_project_pi(self): + ''' + Assert that all projects have at least one PI. + ''' + projects = Project.objects \ + .select_related('status') \ + .order_by('status__name', 'name') + for project in projects: + if not project.pis().exists(): + self.stdout.write(self.style.ERROR( f'{project.status.name} ' + f'Project {project.name} has no PIs.')) + + def handle_user_project(self): + ''' + Assert that all users with a cluster UID should be associated with + at least one Project. + ''' + userprofiles = UserProfile.objects.filter(cluster_uid__isnull=False) \ + .select_related('user') \ + .order_by('user__is_active', 'user__username') \ + .only('id', 'user__first_name', 'user__last_name', + 'user__email', 'user__username', 'user__is_active') + + for userprofile in userprofiles: + user = userprofile.user + user_project_exists = ProjectUser.objects \ + .filter(user=user).exists() + if not user_project_exists: + self.stdout.write(self.style.ERROR( + f'{("Inactive", "Active")[user.is_active]} User ' + f'{user.username} ({user.first_name} ' + f'{user.last_name}, {user.email}) ' + f'has a cluster UID but is not associated with any projects.')) + + def handle_lrc_user_billing(self): + ''' + TODO: Lawrencium features don't work and need to be further developed. + Assert that all LRC users have a billing_activity. + ''' + userprofiles = UserProfile.objects.filter(cluster_uid__isnull=False) \ + .only('id', 'billing_activity', 'user__first_name', + 'user__last_name', 'user__email') + for userprofile in userprofiles: + user = userprofile.user + if userprofile.billing_activity is None: + self.stdout.write(self.style.ERROR( + f'{("Inactive", "Active")[user.is_active]} User ' + f'{user.username} ({user.first_name} ' + f'{user.last_name}, {user.email}) ' + f'has a cluster UID but is not associated with any billing ' + f'activity.')) + + def handle_lrc_recharge_allocation_billing(self): + ''' + TODO: Lawrencium features don't work and need to be further developed. + Assert that all LRC Recharge Allocations have a "Billing Activity"-typed + AllocationAttribute. + ''' + allocations = Allocation.objects \ + .prefetch_related('resources') \ + .filter(resources__name__endswith='Compute') + for allocation in allocations: + if allocation.allocationattribute_set \ + .filter(type='Billing Activity').count() == 0: + self.stdout.write(self.style.ERROR( + f'{allocation.resources.name} Allocation {allocation.id}' + f' has no "Billing Activity"-typed AllocationAttribute.')) + + def handle_lrc_recharge_allocation_user_billing(self): + ''' + TODO: Lawrencium features don't work and need to be further developed. + Assert that all LRC Recharge AllocationUsers have a + "Billing Activity"-typed AllocationUserAttribute. + ''' + allocation_users = AllocationUser.objects \ + .filter(allocation_resources__name__endswith='Compute') + for allocation_user in allocation_users: + if allocation_user.allocationuserattribute_set.filter( + type='Billing Activity').count() == 0: + self.stdout.write(self.style.ERROR( + f'AllocationUser {allocation_user.id} ' + f'({allocation_user.user.first_name} ' + f'{allocation_user.user.last_name}, ' + f'{allocation_user.user.email}) ' + 'has no "Billing Activity"-typed AllocationUserAttribute.')) + + def handle_lrc_pis_lbl_employee(self): + ''' + TODO: Lawrencium features don't work and need to be further developed. + Assert that all LRC PIs are LBL employees. + ''' + pis = UserProfile.objects.filter(cluster_uid__isnull=False, is_pi=True) + for pi in pis: + if not pi.is_lbl_employee(): + self.stdout.write(self.style.ERROR( + f'User {pi.id} ({pi.user.first_name} {pi.user.last_name}, ' + f'{pi.user.email}) is not an LBL employee.')) diff --git a/requirements.txt b/requirements.txt index 20420398e..be92d8b90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,7 +37,7 @@ oauth2client==4.1.3 openpyxl==3.0.9 pandas==1.1.5 phonenumbers==8.12.23 -psycopg2-binary==2.8.3 +psycopg2-binary==2.9.5 pylint==2.12.2 pylint-django==2.5.2 pyparsing==2.4.7