diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..390663a926 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +**/.DS_Store +**/__pycache__ +**/.venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +*/.*_cache +**/.github +*/docs +.readthedocs.y*ml +**/*.db diff --git a/.github/ISSUE_TEMPLATE/bug-fix.yml b/.github/ISSUE_TEMPLATE/bug-fix.yml index 35b0da1d4d..68fbd462ba 100644 --- a/.github/ISSUE_TEMPLATE/bug-fix.yml +++ b/.github/ISSUE_TEMPLATE/bug-fix.yml @@ -11,18 +11,12 @@ body: value: "A bug happened!" validations: required: true - - type: dropdown + - type: input id: version attributes: label: Version description: What version of our software are you running? - options: - - 1.1.5 - - 1.1.4 - - 1.1.3 - - 1.1.2 - - 1.1.1 - - 1.1.0 or before + placeholder: 1.X.X - type: dropdown id: component attributes: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..5a40ce9f53 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + node-version: ["24.x"] + env: + PLUGIN_API: true + DJANGO_VITE_DEV_MODE: true + + steps: + - uses: actions/checkout@v4 + + - name: Install uv and set the python version + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install the project + run: uv sync --locked --dev + + - name: Install frontend packages + run: npm --prefix coldfront/static install + + - name: Check for lint violations + run: uv run ruff check + + - name: Check formatting + run: uv run ruff format --check + + - name: Check frontend with eslint and prettier + run: npm --prefix coldfront/static run check + + - name: Compile and bundle frontend static assets + run: npm --prefix coldfront/static run build + + - name: Check bundled frontend static assets have been commited + run: | + if [[ `git status --porcelain` ]]; then + echo "Error: pre-compiled bundled frontend static assets have not been committed" + git status + exit 1 + else + echo "Bundled frontend static assets check passed." + fi + + - name: Check licence with reuse + run: uv run reuse lint + + - name: Run tests + run: uv run coldfront test + + - name: Check for migrations + run: uv run coldfront makemigrations --check diff --git a/.gitignore b/.gitignore index 8754014b04..7198dfeb8b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ coldfront.egg-info dist build +.zed/ *._* *.DS_Store *.swp @@ -21,3 +22,4 @@ db.json .env .devcontainer/* .bin/* +node_modules diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..e4fba21835 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 4a75e5b695..f7de7204a9 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,17 +7,20 @@ version: 2 # Set the version of Python and other tools you might need build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.9" + python: "3.12" -# Build documentation with MkDocs -mkdocs: - configuration: docs/mkdocs.yml - -# Optionally set the version of Python and requirements required to build your docs -python: - install: - - requirements: docs/requirements.txt - - method: pip - path: . + jobs: + # See: https://docs.readthedocs.io/en/stable/build-customization.html#install-dependencies-with-uv + pre_create_environment: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + create_environment: + - uv venv + install: + - uv sync --group docs + build: + html: + - NO_COLOR=1 uv run mkdocs build --clean --config-file docs/mkdocs.yml --site-dir $READTHEDOCS_OUTPUT/html diff --git a/AUTHORS.md b/AUTHORS.md index 346975cc70..b36b5c0b71 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -31,3 +31,7 @@ - Cecilia Lau - Ria Gupta - Shreyas Sridhar +- David Simpson +- Eric Butcher +- Matthew Kusz +- John LaGrone diff --git a/CHANGELOG.md b/CHANGELOG.md index 150b14aa6b..6ac28aa2f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,105 +1,119 @@ # ColdFront Changelog +## [1.1.7] - 2025-07-22 + +- Automatically change default Slurm account if removal causes conflicts [#597](https://github.com/coldfront/coldfront/pull/597) +- Fix allocation request list displays incorrect date for allocation renewals [#647](https://github.com/coldfront/coldfront/issues/647) +- Add allocation limits for a resource [#667](https://github.com/coldfront/coldfront/pull/667) +- Add REST API [#632](https://github.com/coldfront/coldfront/pull/632) +- Migrate to UV [#677](https://github.com/coldfront/coldfront/pull/677) +- Add EULA enforcement [#671](https://github.com/coldfront/coldfront/pull/671) +- Contiguous Internal Project ID [#646](https://github.com/coldfront/coldfront/pull/646) +- Add auto-compute allocation plugin [#698](https://github.com/coldfront/coldfront/pull/698) +- Add project openldap plugin [#696](https://github.com/coldfront/coldfront/pull/696) +- Add institution feature [#670](https://github.com/coldfront/coldfront/pull/670) +- Update Dockerfile [#715](https://github.com/coldfront/coldfront/pull/715) + ## [1.1.6] - 2024-03-27 -- Upgrade to Django 4.2 LTS [#601](https://github.com/ubccr/coldfront/pull/601) -- Update python version in Dockerfile to 3.8 [#578](https://github.com/ubccr/coldfront/pull/578) -- Add factoryboy Project and Allocation unit tests [#546](https://github.com/ubccr/coldfront/pull/546) -- Add docs for configuring LDAP auth against Active Directory [#556](https://github.com/ubccr/coldfront/pull/556) -- Fix grants formatting error [#442](https://github.com/ubccr/coldfront/issues/442) -- Add docs on creating a plugin [#472](https://github.com/ubccr/coldfront/issues/472) -- Add justification to allocation invoices [#305](https://github.com/ubccr/coldfront/issues/305) -- Add docs on configuring generic OIDC auth [#528](https://github.com/ubccr/coldfront/pull/528) -- Fix bug where notifications were auto-enabled user role changed [#457](https://github.com/ubccr/coldfront/issues/457) -- Add LDAP user search custom mapping and TLS support [#545](https://github.com/ubccr/coldfront/pull/545) -- Add docs on `collect static` for `SITE_STATIC` usage [#358](https://github.com/ubccr/coldfront/issues/358) -- Add signal for new allocation requests [#549](https://github.com/ubccr/coldfront/pull/549) +- Upgrade to Django 4.2 LTS [#601](https://github.com/coldfront/coldfront/pull/601) +- Update python version in Dockerfile to 3.8 [#578](https://github.com/coldfront/coldfront/pull/578) +- Add factoryboy Project and Allocation unit tests [#546](https://github.com/coldfront/coldfront/pull/546) +- Add docs for configuring LDAP auth against Active Directory [#556](https://github.com/coldfront/coldfront/pull/556) +- Fix grants formatting error [#442](https://github.com/coldfront/coldfront/issues/442) +- Add docs on creating a plugin [#472](https://github.com/coldfront/coldfront/issues/472) +- Add justification to allocation invoices [#305](https://github.com/coldfront/coldfront/issues/305) +- Add docs on configuring generic OIDC auth [#528](https://github.com/coldfront/coldfront/pull/528) +- Fix bug where notifications were auto-enabled user role changed [#457](https://github.com/coldfront/coldfront/issues/457) +- Add LDAP user search custom mapping and TLS support [#545](https://github.com/coldfront/coldfront/pull/545) +- Add docs on `collect static` for `SITE_STATIC` usage [#358](https://github.com/coldfront/coldfront/issues/358) +- Add signal for new allocation requests [#549](https://github.com/coldfront/coldfront/pull/549) ## [1.1.5] - 2023-07-12 -- SECURITY BUG FIX: Unprotected eval when adding publication. [#551](https://github.com/ubccr/coldfront/pull/551) +- SECURITY BUG FIX: Unprotected eval when adding publication. [#551](https://github.com/coldfront/coldfront/pull/551) - Documentation improvements ## [1.1.4] - 2023-02-11 -- Datepicker changed to flatpickr. Remove jquery-ui [#438](https://github.com/ubccr/coldfront/issues/438) -- Combined email expiry notifications [#413](https://github.com/ubccr/coldfront/pull/413) -- Remove obsolete arguments in signal defs [#422](https://github.com/ubccr/coldfront/pull/422) -- Allow sorting of users on detail page [#408](https://github.com/ubccr/coldfront/issues/408) -- Fix approve button deleting description text [#433](https://github.com/ubccr/coldfront/issues/433) -- Add Project Attributes [#466](https://github.com/ubccr/coldfront/pull/466) -- Slurm plugin: fix allocations in pending renewal status [#176](https://github.com/ubccr/coldfront/issues/176) -- Update list displayes to sort case insensitive throughout front end [#393](https://github.com/ubccr/coldfront/issues/393) -- Fix FreeIPA plugin not recognizing usernames greater than 11 characters [#416](https://github.com/ubccr/coldfront/issues/416) -- Send signal if allocation status is revoked [#474](https://github.com/ubccr/coldfront/issues/474) +- Datepicker changed to flatpickr. Remove jquery-ui [#438](https://github.com/coldfront/coldfront/issues/438) +- Combined email expiry notifications [#413](https://github.com/coldfront/coldfront/pull/413) +- Remove obsolete arguments in signal defs [#422](https://github.com/coldfront/coldfront/pull/422) +- Allow sorting of users on detail page [#408](https://github.com/coldfront/coldfront/issues/408) +- Fix approve button deleting description text [#433](https://github.com/coldfront/coldfront/issues/433) +- Add Project Attributes [#466](https://github.com/coldfront/coldfront/pull/466) +- Slurm plugin: fix allocations in pending renewal status [#176](https://github.com/coldfront/coldfront/issues/176) +- Update list displayes to sort case insensitive throughout front end [#393](https://github.com/coldfront/coldfront/issues/393) +- Fix FreeIPA plugin not recognizing usernames greater than 11 characters [#416](https://github.com/coldfront/coldfront/issues/416) +- Send signal if allocation status is revoked [#474](https://github.com/coldfront/coldfront/issues/474) - Upgrade to Django 3.2.17 -- Allow configuration of session timeout [#452](https://github.com/ubccr/coldfront/issues/452) -- Increase max length for user first_name [#490](https://github.com/ubccr/coldfront/pull/490) +- Allow configuration of session timeout [#452](https://github.com/coldfront/coldfront/issues/452) +- Increase max length for user first_name [#490](https://github.com/coldfront/coldfront/pull/490) ## [1.1.3] - 2022-07-07 -- Fix erronous allocation change request error message [#428](https://github.com/ubccr/coldfront/issues/428) -- Upgrade bootstrap and move to static assets [#405](https://github.com/ubccr/coldfront/issues/405) +- Fix erronous allocation change request error message [#428](https://github.com/coldfront/coldfront/issues/428) +- Upgrade bootstrap and move to static assets [#405](https://github.com/coldfront/coldfront/issues/405) - Allow changes on allocations in the test dataset -- Add new ColdFront logos and branding [#431](https://github.com/ubccr/coldfront/pull/431) +- Add new ColdFront logos and branding [#431](https://github.com/coldfront/coldfront/pull/431) ## [1.1.2] - 2022-07-06 -- Fix "Select all" toggle for allocations [#396](https://github.com/ubccr/coldfront/issues/396) -- Fixed allocation expiration task bug [#401](https://github.com/ubccr/coldfront/pull/401) -- Fix new user sorting [#395](https://github.com/ubccr/coldfront/issues/395) -- Fix allocation approved status [#379](https://github.com/ubccr/coldfront/issues/379) -- Add notes on project detail page [#194](https://github.com/ubccr/coldfront/issues/194) -- Add partial match for attribute search [#421](https://github.com/ubccr/coldfront/pull/421) -- Fix miscellaneous config issues [#414](https://github.com/ubccr/coldfront/issues/414) +- Fix "Select all" toggle for allocations [#396](https://github.com/coldfront/coldfront/issues/396) +- Fixed allocation expiration task bug [#401](https://github.com/coldfront/coldfront/pull/401) +- Fix new user sorting [#395](https://github.com/coldfront/coldfront/issues/395) +- Fix allocation approved status [#379](https://github.com/coldfront/coldfront/issues/379) +- Add notes on project detail page [#194](https://github.com/coldfront/coldfront/issues/194) +- Add partial match for attribute search [#421](https://github.com/coldfront/coldfront/pull/421) +- Fix miscellaneous config issues [#414](https://github.com/coldfront/coldfront/issues/414) - Upgrade to Django 3.2.14 ## [1.1.1] - 2022-04-26 -- Fix grant export to only download those found under search [#222](https://github.com/ubccr/coldfront/issues/222) -- Fix bug that allowed users to be added to inactive allocations [#386](https://github.com/ubccr/coldfront/issues/386) -- Fix allocation request approval redirect [#388](https://github.com/ubccr/coldfront/issues/388) +- Fix grant export to only download those found under search [#222](https://github.com/coldfront/coldfront/issues/222) +- Fix bug that allowed users to be added to inactive allocations [#386](https://github.com/coldfront/coldfront/issues/386) +- Fix allocation request approval redirect [#388](https://github.com/coldfront/coldfront/issues/388) - Upgrade to Django 3.2.13 -- Fix bug in slurm plugin where `SLURM_NOOP` was a str instead of a bool [#392](https://github.com/ubccr/coldfront/pull/392) +- Fix bug in slurm plugin where `SLURM_NOOP` was a str instead of a bool [#392](https://github.com/coldfront/coldfront/pull/392) ## [1.1.0] - 2022-03-09 -- Add a checkbox to 'select all' users on the project to enable/disable notifications [#291](https://github.com/ubccr/coldfront/issues/291) -- Archived grant not viewable by PI [#259](https://github.com/ubccr/coldfront/issues/259) -- Add more detail info when multiple allocations on a project for same resource [#193](https://github.com/ubccr/coldfront/issues/193) -- Admins can prevent the renewal of allocations [#203](https://github.com/ubccr/coldfront/issues/203) -- Allow logout redirect URL to be configured [#311](https://github.com/ubccr/coldfront/pull/311) -- Fix empty user search exception [#313](https://github.com/ubccr/coldfront/issues/313) -- Add allocation change requests [#294](https://github.com/ubccr/coldfront/issues/294) -- Added signal dispatch for resource allocations [#319](https://github.com/ubccr/coldfront/pull/319) -- mokey oidc plugin: Handle groups claim as list [#332](https://github.com/ubccr/coldfront/pull/332) -- Fix divide by zero error when attribute that has 0 usage [#336](https://github.com/ubccr/coldfront/issues/336) -- Allocation request flow updates [#341](https://github.com/ubccr/coldfront/issues/341) -- Add attribute expansion support [#324](https://github.com/ubccr/coldfront/pull/324) -- Fix adding not-selected publications [#343](https://github.com/ubccr/coldfront/pull/343) -- Add forward filter parameters between active-archived projects search pages [#347](https://github.com/ubccr/coldfront/pull/347) -- Fix sorting arrows for allocation search [#344](https://github.com/ubccr/coldfront/pull/344) -- SECURITY BUG FIX: Check permissions on notification updates [#348](https://github.com/ubccr/coldfront/pull/348) -- Allow site-level control of how resources ordered within an allocation [#334](https://github.com/ubccr/coldfront/issues/334) -- LDAP user search plugin: Add ldap connect timeout config option [#351](https://github.com/ubccr/coldfront/pull/351) -- Upgrade to Django v3.2 [#295](https://github.com/ubccr/coldfront/issues/295) -- Fix error on duplicate publication entry [#369](https://github.com/ubccr/coldfront/issues/369) -- Add resource list page [#323](https://github.com/ubccr/coldfront/issues/322) -- Add resource detail page [#320](https://github.com/ubccr/coldfront/issues/320) -- Fix adding publications with large number of authors [#283](https://github.com/ubccr/coldfront/issues/283) -- Allow allocation users to see allocations of all statuses [#292](https://github.com/ubccr/coldfront/issues/292) -- Show allocations for both Active and New projects [#365](https://github.com/ubccr/coldfront/pull/365) +- Add a checkbox to 'select all' users on the project to enable/disable notifications [#291](https://github.com/coldfront/coldfront/issues/291) +- Archived grant not viewable by PI [#259](https://github.com/coldfront/coldfront/issues/259) +- Add more detail info when multiple allocations on a project for same resource [#193](https://github.com/coldfront/coldfront/issues/193) +- Admins can prevent the renewal of allocations [#203](https://github.com/coldfront/coldfront/issues/203) +- Allow logout redirect URL to be configured [#311](https://github.com/coldfront/coldfront/pull/311) +- Fix empty user search exception [#313](https://github.com/coldfront/coldfront/issues/313) +- Add allocation change requests [#294](https://github.com/coldfront/coldfront/issues/294) +- Added signal dispatch for resource allocations [#319](https://github.com/coldfront/coldfront/pull/319) +- mokey oidc plugin: Handle groups claim as list [#332](https://github.com/coldfront/coldfront/pull/332) +- Fix divide by zero error when attribute that has 0 usage [#336](https://github.com/coldfront/coldfront/issues/336) +- Allocation request flow updates [#341](https://github.com/coldfront/coldfront/issues/341) +- Add attribute expansion support [#324](https://github.com/coldfront/coldfront/pull/324) +- Fix adding not-selected publications [#343](https://github.com/coldfront/coldfront/pull/343) +- Add forward filter parameters between active-archived projects search pages [#347](https://github.com/coldfront/coldfront/pull/347) +- Fix sorting arrows for allocation search [#344](https://github.com/coldfront/coldfront/pull/344) +- SECURITY BUG FIX: Check permissions on notification updates [#348](https://github.com/coldfront/coldfront/pull/348) +- Allow site-level control of how resources ordered within an allocation [#334](https://github.com/coldfront/coldfront/issues/334) +- LDAP user search plugin: Add ldap connect timeout config option [#351](https://github.com/coldfront/coldfront/pull/351) +- Upgrade to Django v3.2 [#295](https://github.com/coldfront/coldfront/issues/295) +- Fix error on duplicate publication entry [#369](https://github.com/coldfront/coldfront/issues/369) +- Add resource list page [#323](https://github.com/coldfront/coldfront/issues/322) +- Add resource detail page [#320](https://github.com/coldfront/coldfront/issues/320) +- Fix adding publications with large number of authors [#283](https://github.com/coldfront/coldfront/issues/283) +- Allow allocation users to see allocations of all statuses [#292](https://github.com/coldfront/coldfront/issues/292) +- Show allocations for both Active and New projects [#365](https://github.com/coldfront/coldfront/pull/365) ## [1.0.4] - 2021-03-25 -- Slurm plugin: disabled resource should not show up in slurm files [#235](https://github.com/ubccr/coldfront/issues/235) -- Fix ldap config [#271](https://github.com/ubccr/coldfront/issues/271) -- Add sample csv data to pip packaging [#279](https://github.com/ubccr/coldfront/issues/279) +- Slurm plugin: disabled resource should not show up in slurm files [#235](https://github.com/coldfront/coldfront/issues/235) +- Fix ldap config [#271](https://github.com/coldfront/coldfront/issues/271) +- Add sample csv data to pip packaging [#279](https://github.com/coldfront/coldfront/issues/279) - Add LDAP User Search plugin configs ## [1.0.3] - 2021-03-02 -- Refactor ColdFront settings [#264](https://github.com/ubccr/coldfront/pull/264) +- Refactor ColdFront settings [#264](https://github.com/coldfront/coldfront/pull/264) - Lots of documenation updates now hosted on readthedocs. [see here](https://coldfront.readthedocs.io) - Fix setuptools for pip installs @@ -136,16 +150,17 @@ - Initial release -[0.0.1]: https://github.com/ubccr/coldfront/releases/tag/v0.0.1 -[1.0.1]: https://github.com/ubccr/coldfront/releases/tag/v1.0.1 -[1.0.2]: https://github.com/ubccr/coldfront/releases/tag/v1.0.2 -[1.0.3]: https://github.com/ubccr/coldfront/releases/tag/v1.0.3 -[1.0.4]: https://github.com/ubccr/coldfront/releases/tag/v1.0.4 -[1.1.0]: https://github.com/ubccr/coldfront/releases/tag/v1.1.0 -[1.1.1]: https://github.com/ubccr/coldfront/releases/tag/v1.1.1 -[1.1.2]: https://github.com/ubccr/coldfront/releases/tag/v1.1.2 -[1.1.3]: https://github.com/ubccr/coldfront/releases/tag/v1.1.3 -[1.1.4]: https://github.com/ubccr/coldfront/releases/tag/v1.1.4 -[1.1.5]: https://github.com/ubccr/coldfront/releases/tag/v1.1.5 -[1.1.6]: https://github.com/ubccr/coldfront/releases/tag/v1.1.6 -[Unreleased]: https://github.com/ubccr/coldfront/compare/v1.1.6...HEAD +[0.0.1]: https://github.com/coldfront/coldfront/releases/tag/v0.0.1 +[1.0.1]: https://github.com/coldfront/coldfront/releases/tag/v1.0.1 +[1.0.2]: https://github.com/coldfront/coldfront/releases/tag/v1.0.2 +[1.0.3]: https://github.com/coldfront/coldfront/releases/tag/v1.0.3 +[1.0.4]: https://github.com/coldfront/coldfront/releases/tag/v1.0.4 +[1.1.0]: https://github.com/coldfront/coldfront/releases/tag/v1.1.0 +[1.1.1]: https://github.com/coldfront/coldfront/releases/tag/v1.1.1 +[1.1.2]: https://github.com/coldfront/coldfront/releases/tag/v1.1.2 +[1.1.3]: https://github.com/coldfront/coldfront/releases/tag/v1.1.3 +[1.1.4]: https://github.com/coldfront/coldfront/releases/tag/v1.1.4 +[1.1.5]: https://github.com/coldfront/coldfront/releases/tag/v1.1.5 +[1.1.6]: https://github.com/coldfront/coldfront/releases/tag/v1.1.6 +[1.1.7]: https://github.com/coldfront/coldfront/releases/tag/v1.1.7 +[Unreleased]: https://github.com/coldfront/coldfront/compare/v1.1.7...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..3a996ee1e5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,67 @@ +# Contributing to ColdFront + +Before we start, thank you for considering contributing to ColdFront! + +Every contribution no matter how small is welcome here! Whether you are adding a whole new feature, improving the aesthetics of the webpage, adding some tests cases, or just fixing a typo your contributions are appreciated. + +In this document you will find a set a guidelines for contributing to the ColdFront project, not hard rules. However, sticking to the advice contained in this document will help you to make better contributions, save time, and increase the chances of your contributions getting accepted. + +This project abides by a [Code of Conduct](CODE_OF_CONDUCT.md) that all contributors are required to uphold. Please read this document before interacting with the project. + +In addition, you must sign off on all commits using `git commit -s`, acknowledging you agree to the Developer Certificate of Origin. + +## Contributor's Agreement + +You are under no obligation whatsoever to provide any bug fixes, patches, or upgrades to the features, functionality or performance of the source code ("Enhancements") to anyone; however, if you choose to make your Enhancements available either publicly, or directly to the project, without imposing a separate written license agreement for such Enhancements, then you hereby grant the following license: a non-exclusive, royalty-free perpetual license to install, use, modify, prepare derivative works, incorporate into other computer software, distribute, and sublicense such enhancements or derivative works thereof, in binary and source code form. + +## Contribution Workflow + +For most contributions, you will start by opening up an issue, discussing changes with maintainers and the community, and then opening a pull request. It is perfectly acceptable to provide a pull request without opening an issue first. However, we recommend you open an issue if the changes you suggest are significant to ensure your changes can be successfully merged. + +### Issues + +We track requested changes using GitHub issues. These include bugs, feature requests, and general concerns. + +Before making an issue, please look at current and previous issues to make sure that your concern has not already been raised by someone else. It is also advised to read through the [current documentation](https://coldfront.readthedocs.io/en/stable/). If an issue with your concern is already opened you are encouraged to comment further on it. The `Search Issues` feature is great to check to see if someone has already raised your issue before. + +If, after searching pre-existing issues, your concern has not been raised (or you are unsure if a previous issue covers your concern) please open a new issue with any labels you believe are relevant. Please include any relevant images, links, and syntax-highlighted text snippets to help maintainers understand your concerns better. It is also helpful to include any debugging steps you have attempted or ideas on how to fix your issue. + +### Pull Requests + +To create a pull request: + +1. Fork this repository. +2. Create a branch off the `main` branch. +3. Make commits to your branch. Make sure your additions include [testing](#testing) for any new functionality. Make sure that you run the full test suite on your PR before submitting. If your changes necessitate removing or changing previous tests, please state this explicitly in your PR. Also ensure that your changes pass the [linter and formatter](#formatting-and-linting). +4. Create a pull request back to this main repository. +5. Wait for a maintainer to review your code and request any changes. Don't worry if the maintainer asks for changes! This feedback is perfectly normal and ensures a more maintainable project for everyone. + +## Conventions and Style Guide + +#### Spelling and Naming + +Please use a spell-checker when modifying the codebase to reduce the prevalence of typos. You should avoid writing names in code that use jargon or abbreviations that are not directly relevant to ColdFront or tools used in its development. + +#### Annotations + +You are encouraged to use Python's type annotations to improve code readability and maintainability. Whenever possible, use the most recent annotation syntax available for the minimum version of Python supported by ColdFront. + +> The minimum Python version supported can be found in the `pyproject.toml` file. + +#### Testing + +All new and changed features must include unit tests to verify that they work correctly. Every non-trivial function should have at least as many test cases as its cyclomatic complexity to verify all independent code paths in the function operate correctly. + +When using [uv](https://docs.astral.sh/uv/), the full test suite can be run using the command `uv run coldfront test`. + +#### Formatting and Linting + +This project is formatted and linted using [ruff](https://docs.astral.sh/ruff/). + +You can use Ruff to check for any linting errors in proposed Python code using `uv run ruff check`. Ruff can also fix many linting errors automatically with `uv run ruff check --fix` when using [uv](https://docs.astral.sh/uv/). + +If your code is failing linting checks but you you have a valid reason to leave it unchanged, you can suppress warnings using a `# noqa: ` comment on the line(s) in question + +You can also use Ruff to check formatting using `uv run ruff format --check` and automatically fix formatting errors with `uv run ruff format`. + +If your code is failing formatting checks but you have a valid reason to leave it unchanged, you can suppress warnings for a specific block of code by enclosing it with the comments `# fmt: off` and `# fmt: on`. These comments work at the statement level so placing them inside of expressions will not have any effect. diff --git a/Dockerfile b/Dockerfile index cb550c69cb..17330ac492 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,74 @@ -FROM python:3.8 +FROM ubuntu:24.04 AS base -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - && rm -rf /var/lib/apt/lists/* +FROM base AS cfimage -WORKDIR /usr/src/app -COPY requirements.txt ./ -RUN pip3 install -r requirements.txt -COPY . . +RUN apt update && DEBIAN_FRONTEND=noninteractive apt install -y --no-install-recommends \ + sqlite3 \ + freeipa-client \ + mariadb-client \ + postgresql-client -RUN python3 ./manage.py initial_setup -RUN python3 ./manage.py load_test_data +FROM cfimage AS builder -ENV DEBUG=True +RUN DEBIAN_FRONTEND=noninteractive apt install -y \ + gcc \ + cmake \ + pkg-config \ + build-essential \ + libmariadb-dev \ + libpq-dev \ + libssl-dev \ + libdbus-1-dev \ + libldap2-dev \ + libkrb5-dev \ + libglib2.0-dev \ + libsasl2-dev +COPY --from=ghcr.io/astral-sh/uv:0.7 /uv /bin/uv +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_PYTHON_INSTALL_DIR=/python +ENV UV_PYTHON_PREFERENCE=only-managed + +WORKDIR /app + +# Install Python before the project for caching +RUN --mount=type=bind,source=.python-version,target=.python-version \ + uv python install + +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=.python-version,target=.python-version \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync \ + --locked \ + --no-install-project \ + --no-dev \ + --extra ldap \ + --extra freeipa \ + --extra iquota \ + --extra oidc \ + --extra mysql \ + --extra pg +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync \ + --locked \ + --no-dev \ + --extra ldap \ + --extra freeipa \ + --extra iquota \ + --extra oidc \ + --extra mysql \ + --extra pg + + +FROM cfimage + +RUN apt-get clean && rm -rf /var/lib/apt/lists/* +RUN groupadd -r python && useradd -r -g python python +COPY --from=builder --chown=python:python /python /python +COPY --from=builder /app /app +ENV PATH="/app/.venv/bin:$PATH" EXPOSE 8000 -CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] +CMD ["gunicorn", "--workers", "3", "--bind", ":8000", "coldfront.config.wsgi"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 94a9ed024d..0000000000 --- a/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/LICENSES/AGPL-3.0-or-later.txt b/LICENSES/AGPL-3.0-or-later.txt new file mode 100644 index 0000000000..0c97efd25b --- /dev/null +++ b/LICENSES/AGPL-3.0-or-later.txt @@ -0,0 +1,235 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/LICENSES/CC-BY-NC-ND-4.0.txt b/LICENSES/CC-BY-NC-ND-4.0.txt new file mode 100644 index 0000000000..6f2a684c1a --- /dev/null +++ b/LICENSES/CC-BY-NC-ND-4.0.txt @@ -0,0 +1,155 @@ +Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International + + Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. + +Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. + +Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. + +Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. + +Section 1 – Definitions. + + a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. + + b. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. + + c. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. + + d. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. + + e. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. + + f. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. + + g. Licensor means the individual(s) or entity(ies) granting rights under this Public License. + + h. NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange. + + i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. + +Section 2 – Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: + + A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and + + B. produce and reproduce, but not Share, Adapted Material for NonCommercial purposes only. + + 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. + + 3. Term. The term of this Public License is specified in Section 6(a). + + 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. + + 5. Downstream recipients. + A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. + + B. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. + + 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this Public License. + + 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes. + +Section 3 – License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material, You must: + + A. retain the following if it is supplied by the Licensor with the Licensed Material: + + i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of warranties; + + v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; + + B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and + + C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. + + For the avoidance of doubt, You do not have permission under this Public License to Share Adapted Material. + + 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. + + 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. + +Section 4 – Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only and provided You do not Share Adapted Material; + + b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. +For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. + +Section 5 – Disclaimer of Warranties and Limitation of Liability. + + a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. + + b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. + + c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. + +Section 6 – Term and Termination. + + a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. + +Section 7 – Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. + +Section 8 – Interpretation. + + a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. + + c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. + + d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. + +Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000000..0e259d42c9 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000000..d817195dad --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in index e5bdd3ff21..4894e3e1b3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,3 +11,4 @@ recursive-include coldfront/core/publication/templates * recursive-include coldfront/core/grant/templates * recursive-include coldfront/core/user/templates * recursive-include coldfront/core/resource/templates * +recursive-include coldfront/plugins/slurm/templates * diff --git a/README.md b/README.md index f3cf1dbb43..9729f4df83 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,4 @@ subscribe ccr-open-coldfront-list@listserv.buffalo.edu first_name last_name ## License -ColdFront is released under the GPLv3 license. See the LICENSE file. +ColdFront is released under the AGPLv3 license. See REUSE.toml. diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000000..4dd0ba8aa8 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,66 @@ +version = 1 +SPDX-PackageName = "coldfront" +SPDX-PackageDownloadLocation = "https://github.com/ubccr/coldfront" + +[[annotations]] +path = [ + ".python-version", + "Dockerfile", + ".dockerignore", + "MANIFEST.in", + "docs/pages/.pages", + "pyproject.toml", + "**.yaml", + "**.yml", + "**.md", + "**.txt", + "**.csv", + "**.sql", + "**.gitignore", + "**.html", + "**.css", + "**.scss", + "**.js", + "**.ts", + "**.webmanifest", +] +SPDX-FileCopyrightText = "(C) ColdFront Authors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + +[[annotations]] +path = [ + "docs/pages/images/*", + "docs/pages/assets/*", + "**.ico", +] +SPDX-FileCopyrightText = "(C) ColdFront Authors" +SPDX-License-Identifier = "CC-BY-NC-ND-4.0" + +[[annotations]] +path = [ + "uv.lock", + "coldfront/static/package-lock.json", + "coldfront/static/bundles/manifest.json", + "coldfront/static/package.json", + "coldfront/static/tsconfig.json", + "coldfront/static/.prettierignore", + "coldfront/static/.prettierrc", + "coldfront/**.png", + "coldfront/**.jpg", +] +SPDX-FileCopyrightText = "NONE" +SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = [ + "coldfront/static/bundles/fa-*.woff2", +] +SPDX-FileCopyrightText = "Copyright (c) 2011-2025 The Bootstrap Authors" +SPDX-License-Identifier = "MIT" + +[[annotations]] +path = [ + "coldfront/static/bundles/c3.js", +] +SPDX-FileCopyrightText = "Copyright (c) 2013 Masayuki Tanaka" +SPDX-License-Identifier = "MIT" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..647f1d033c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Vulnerability Disclosure + +If you believe you have discovered a vulnerability in ColdFront, please let us know. You can notify the ColdFront team by email at [ccr-cfsec@buffalo.edu](mailto:ccr-cfsec@buffalo.edu). + +We disclose vulnerabilities found in ColdFront through notifications on our community channels. We encourage all users to monitor new releases of ColdFront for security information. Security patches are applied to the latest release. \ No newline at end of file diff --git a/coldfront/__init__.py b/coldfront/__init__.py index 912fb13f1c..d53cbb860f 100644 --- a/coldfront/__init__.py +++ b/coldfront/__init__.py @@ -1,11 +1,16 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import os import sys -__version__ = '1.1.6' +__version__ = "1.1.7" VERSION = __version__ def manage(): os.environ.setdefault("DJANGO_SETTINGS_MODULE", "coldfront.config.settings") from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) diff --git a/coldfront/config/__init__.py b/coldfront/config/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/config/__init__.py +++ b/coldfront/config/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/config/auth.py b/coldfront/config/auth.py index 6e7f865bd0..3dc2d9e27e 100644 --- a/coldfront/config/auth.py +++ b/coldfront/config/auth.py @@ -1,29 +1,39 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from coldfront.config.base import AUTHENTICATION_BACKENDS, INSTALLED_APPS, TEMPLATES from coldfront.config.env import ENV -from coldfront.config.base import INSTALLED_APPS, AUTHENTICATION_BACKENDS, TEMPLATES -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # ColdFront default authentication settings -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ AUTHENTICATION_BACKENDS += [ - 'django.contrib.auth.backends.ModelBackend', + "django.contrib.auth.backends.ModelBackend", ] -LOGIN_URL = '/user/login' -LOGIN_REDIRECT_URL = '/' -LOGOUT_REDIRECT_URL = ENV.str('LOGOUT_REDIRECT_URL', LOGIN_URL) +LOGIN_URL = "/user/login" +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = ENV.str("LOGOUT_REDIRECT_URL", LOGIN_URL) SU_LOGIN_CALLBACK = "coldfront.core.utils.common.su_login_callback" SU_LOGOUT_REDIRECT_URL = "/admin/auth/user/" -SESSION_COOKIE_AGE = ENV.int('SESSION_INACTIVITY_TIMEOUT', default=60 * 60) +SESSION_COOKIE_AGE = ENV.int("SESSION_INACTIVITY_TIMEOUT", default=60 * 60) SESSION_SAVE_EVERY_REQUEST = True -SESSION_COOKIE_SAMESITE = 'Strict' +SESSION_COOKIE_SAMESITE = "Strict" SESSION_COOKIE_SECURE = True -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable administrators to login as other users -#------------------------------------------------------------------------------ -if ENV.bool('ENABLE_SU', default=True): - AUTHENTICATION_BACKENDS += ['django_su.backends.SuBackend', ] - INSTALLED_APPS.insert(0, 'django_su') - TEMPLATES[0]['OPTIONS']['context_processors'].extend(['django_su.context_processors.is_su', ]) +# ------------------------------------------------------------------------------ +if ENV.bool("ENABLE_SU", default=True): + AUTHENTICATION_BACKENDS += [ + "django_su.backends.SuBackend", + ] + INSTALLED_APPS.insert(0, "django_su") + TEMPLATES[0]["OPTIONS"]["context_processors"].extend( + [ + "django_su.context_processors.is_su", + ] + ) diff --git a/coldfront/config/base.py b/coldfront/config/base.py index d9f71d972f..87e2b58355 100644 --- a/coldfront/config/base.py +++ b/coldfront/config/base.py @@ -1,157 +1,177 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + """ Base Django settings for ColdFront project. """ + +import importlib.util import os -import sys -import coldfront + from django.core.exceptions import ImproperlyConfigured from django.core.management.utils import get_random_secret_key + +import coldfront from coldfront.config.env import ENV, PROJECT_ROOT -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Base Django config for ColdFront -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ VERSION = coldfront.VERSION BASE_DIR = PROJECT_ROOT() -ALLOWED_HOSTS = ENV.list('ALLOWED_HOSTS', default=['*']) -DEBUG = ENV.bool('DEBUG', default=False) -WSGI_APPLICATION = 'coldfront.config.wsgi.application' -ROOT_URLCONF = 'coldfront.config.urls' +ALLOWED_HOSTS = ENV.list("ALLOWED_HOSTS", default=["*"]) +DEBUG = ENV.bool("DEBUG", default=False) +WSGI_APPLICATION = "coldfront.config.wsgi.application" +ROOT_URLCONF = "coldfront.config.urls" -SECRET_KEY = ENV.str('SECRET_KEY', default='') +SECRET_KEY = ENV.str("SECRET_KEY", default="") if len(SECRET_KEY) == 0: SECRET_KEY = get_random_secret_key() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Locale settings -#------------------------------------------------------------------------------ -LANGUAGE_CODE = ENV.str('LANGUAGE_CODE', default='en-us') -TIME_ZONE = ENV.str('TIME_ZONE', default='America/New_York') +# ------------------------------------------------------------------------------ +LANGUAGE_CODE = ENV.str("LANGUAGE_CODE", default="en-us") +TIME_ZONE = ENV.str("TIME_ZONE", default="America/New_York") USE_I18N = True -USE_L10N = True USE_TZ = True -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Django Apps -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/3.2/releases/3.2/#customizing-type-of-auto-created-primary-keys # We should change this to BigAutoField at some point -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.humanize', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", ] -# Additional Apps -# Hack to fix fontawesome. Will be fixed in version 6 -sys.modules['fontawesome_free'] = __import__('fontawesome-free') INSTALLED_APPS += [ - 'crispy_forms', - 'crispy_bootstrap4', - 'sslserver', - 'django_q', - 'simple_history', - 'fontawesome_free', + "crispy_forms", + "crispy_bootstrap5", + "django_q", + "simple_history", + "django_vite", + "django_htmx", ] +if DEBUG and importlib.util.find_spec("sslserver") is not None: + INSTALLED_APPS += [ + "sslserver", + ] + # ColdFront Apps INSTALLED_APPS += [ - 'coldfront.core.user', - 'coldfront.core.field_of_science', - 'coldfront.core.utils', - 'coldfront.core.portal', - 'coldfront.core.project', - 'coldfront.core.resource', - 'coldfront.core.allocation', - 'coldfront.core.grant', - 'coldfront.core.publication', - 'coldfront.core.research_output', + "coldfront.core.user", + "coldfront.core.field_of_science", + "coldfront.core.utils", + "coldfront.core.portal", + "coldfront.core.project", + "coldfront.core.resource", + "coldfront.core.allocation", + "coldfront.core.grant", + "coldfront.core.publication", + "coldfront.core.research_output", ] -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Django Middleware -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'simple_history.middleware.HistoryRequestMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "simple_history.middleware.HistoryRequestMiddleware", + "django_htmx.middleware.HtmxMiddleware", ] -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Django authentication backend. See auth.py -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ AUTHENTICATION_BACKENDS = [] -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Django Q -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ Q_CLUSTER = { - 'timeout': ENV.int('Q_CLUSTER_TIMEOUT', default=120), - 'retry': ENV.int('Q_CLUSTER_RETRY', default=120), + "timeout": ENV.int("Q_CLUSTER_TIMEOUT", default=120), + "retry": ENV.int("Q_CLUSTER_RETRY", default=120), } -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Django template and site settings -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - PROJECT_ROOT('site/templates'), - '/usr/share/coldfront/site/templates', - PROJECT_ROOT('coldfront/templates'), + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + PROJECT_ROOT("site/templates"), + "/usr/share/coldfront/site/templates", + PROJECT_ROOT("coldfront/templates"), ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - 'django_settings_export.settings_export', + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django_settings_export.settings_export", ], }, }, ] # Add local site templates files if set -SITE_TEMPLATES = ENV.str('SITE_TEMPLATES', default='') +SITE_TEMPLATES = ENV.str("SITE_TEMPLATES", default="") if len(SITE_TEMPLATES) > 0: if os.path.isdir(SITE_TEMPLATES): - TEMPLATES[0]['DIRS'].insert(0, SITE_TEMPLATES) + TEMPLATES[0]["DIRS"].insert(0, SITE_TEMPLATES) else: - raise ImproperlyConfigured('SITE_TEMPLATES should be a path to a directory') + raise ImproperlyConfigured("SITE_TEMPLATES should be a path to a directory") + +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" +CRISPY_TEMPLATE_PACK = "bootstrap5" -CRISPY_TEMPLATE_PACK = 'bootstrap4' SETTINGS_EXPORT = [] -STATIC_URL = '/static/' -STATIC_ROOT = ENV.str('STATIC_ROOT', default=PROJECT_ROOT('static_root')) +STATIC_URL = "/static/" + +DJANGO_VITE = { + "default": { + "dev_mode": ENV.bool("DJANGO_VITE_DEV_MODE", default=False), + "dev_server_port": ENV.int("DJANGO_VITE_SERVER_PORT", default=5173), + } +} + +STATIC_ROOT = ENV.str("STATIC_ROOT", default=PROJECT_ROOT("static_root")) STATICFILES_DIRS = [ - PROJECT_ROOT('coldfront/static'), + PROJECT_ROOT("coldfront/static/bundles"), + PROJECT_ROOT("coldfront/static/assets"), ] # Add local site static files if set -SITE_STATIC = ENV.str('SITE_STATIC', default='') +SITE_STATIC = ENV.str("SITE_STATIC", default="") if len(SITE_STATIC) > 0: if os.path.isdir(SITE_STATIC): STATICFILES_DIRS.insert(0, SITE_STATIC) else: - raise ImproperlyConfigured('SITE_STATIC should be a path to a directory') + raise ImproperlyConfigured("SITE_STATIC should be a path to a directory") # Add system site static files -if os.path.isdir('/usr/share/coldfront/site/static'): - STATICFILES_DIRS.insert(0, '/usr/share/coldfront/site/static') +if os.path.isdir("/usr/share/coldfront/site/static"): + STATICFILES_DIRS.insert(0, "/usr/share/coldfront/site/static") diff --git a/coldfront/config/core.py b/coldfront/config/core.py index 10f2b8a6ea..2154c9de0e 100644 --- a/coldfront/config/core.py +++ b/coldfront/config/core.py @@ -1,79 +1,105 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.base import SETTINGS_EXPORT from coldfront.config.env import ENV -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Advanced ColdFront configurations -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # General Center Information -#------------------------------------------------------------------------------ -CENTER_NAME = ENV.str('CENTER_NAME', default='HPC Center') -CENTER_HELP_URL = ENV.str('CENTER_HELP_URL', default='') -CENTER_PROJECT_RENEWAL_HELP_URL = ENV.str('CENTER_PROJECT_RENEWAL_HELP_URL', default='') -CENTER_BASE_URL = ENV.str('CENTER_BASE_URL', default='') +# ------------------------------------------------------------------------------ +CENTER_NAME = ENV.str("CENTER_NAME", default="HPC Center") +CENTER_HELP_URL = ENV.str("CENTER_HELP_URL", default="") +CENTER_PROJECT_RENEWAL_HELP_URL = ENV.str("CENTER_PROJECT_RENEWAL_HELP_URL", default="") +CENTER_BASE_URL = ENV.str("CENTER_BASE_URL", default="") -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable Research Outputs, Grants, Publications -#------------------------------------------------------------------------------ -RESEARCH_OUTPUT_ENABLE = ENV.bool('RESEARCH_OUTPUT_ENABLE', default=True) -GRANT_ENABLE = ENV.bool('GRANT_ENABLE', default=True) -PUBLICATION_ENABLE = ENV.bool('PUBLICATION_ENABLE', default=True) +# ------------------------------------------------------------------------------ +RESEARCH_OUTPUT_ENABLE = ENV.bool("RESEARCH_OUTPUT_ENABLE", default=True) +GRANT_ENABLE = ENV.bool("GRANT_ENABLE", default=True) +PUBLICATION_ENABLE = ENV.bool("PUBLICATION_ENABLE", default=True) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable Project Review -#------------------------------------------------------------------------------ -PROJECT_ENABLE_PROJECT_REVIEW = ENV.bool('PROJECT_ENABLE_PROJECT_REVIEW', default=True) +# ------------------------------------------------------------------------------ +PROJECT_ENABLE_PROJECT_REVIEW = ENV.bool("PROJECT_ENABLE_PROJECT_REVIEW", default=True) + +# ------------------------------------------------------------------------------ +# Enable EULA force agreement +# ------------------------------------------------------------------------------ +ALLOCATION_EULA_ENABLE = ENV.bool("ALLOCATION_EULA_ENABLE", default=False) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Allocation related -#------------------------------------------------------------------------------ -ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT = ENV.bool('ALLOCATION_ENABLE_CHANGE_REQUESTS', default=True) -ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = ENV.list('ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS', cast=int, default=[30, 60, 90]) -ALLOCATION_ENABLE_ALLOCATION_RENEWAL = ENV.bool('ALLOCATION_ENABLE_ALLOCATION_RENEWAL', default=True) -ALLOCATION_FUNCS_ON_EXPIRE = ['coldfront.core.allocation.utils.test_allocation_function', ] +# ------------------------------------------------------------------------------ +ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT = ENV.bool("ALLOCATION_ENABLE_CHANGE_REQUESTS", default=True) +ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = ENV.list( + "ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS", cast=int, default=[30, 60, 90] +) +ALLOCATION_ENABLE_ALLOCATION_RENEWAL = ENV.bool("ALLOCATION_ENABLE_ALLOCATION_RENEWAL", default=True) +ALLOCATION_FUNCS_ON_EXPIRE = [ + "coldfront.core.allocation.utils.test_allocation_function", +] # This is in days -ALLOCATION_DEFAULT_ALLOCATION_LENGTH = ENV.int('ALLOCATION_DEFAULT_ALLOCATION_LENGTH', default=365) - +ALLOCATION_DEFAULT_ALLOCATION_LENGTH = ENV.int("ALLOCATION_DEFAULT_ALLOCATION_LENGTH", default=365) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Allow user to select account name for allocation -#------------------------------------------------------------------------------ -ALLOCATION_ACCOUNT_ENABLED = ENV.bool('ALLOCATION_ACCOUNT_ENABLED', default=False) -ALLOCATION_ACCOUNT_MAPPING = ENV.dict('ALLOCATION_ACCOUNT_MAPPING', default={}) +# ------------------------------------------------------------------------------ +ALLOCATION_ACCOUNT_ENABLED = ENV.bool("ALLOCATION_ACCOUNT_ENABLED", default=False) +ALLOCATION_ACCOUNT_MAPPING = ENV.dict("ALLOCATION_ACCOUNT_MAPPING", default={}) SETTINGS_EXPORT += [ - 'ALLOCATION_ACCOUNT_ENABLED', - 'CENTER_HELP_URL', - 'RESEARCH_OUTPUT_ENABLE', - 'GRANT_ENABLE', - 'PUBLICATION_ENABLE', + "ALLOCATION_ACCOUNT_ENABLED", + "ALLOCATION_DEFAULT_ALLOCATION_LENGTH", + "ALLOCATION_ENABLE_ALLOCATION_RENEWAL", + "ALLOCATION_EULA_ENABLE", + "CENTER_HELP_URL", + "GRANT_ENABLE", + "INVOICE_ENABLED", + "PROJECT_ENABLE_PROJECT_REVIEW", + "PROJECT_INSTITUTION_EMAIL_MAP", + "PUBLICATION_ENABLE", + "RESEARCH_OUTPUT_ENABLE", + "DJANGO_VITE", ] -ADMIN_COMMENTS_SHOW_EMPTY = ENV.bool('ADMIN_COMMENTS_SHOW_EMPTY', default=True) +ADMIN_COMMENTS_SHOW_EMPTY = ENV.bool("ADMIN_COMMENTS_SHOW_EMPTY", default=True) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # List of Allocation Attributes to display on view page -#------------------------------------------------------------------------------ -ALLOCATION_ATTRIBUTE_VIEW_LIST = ENV.list('ALLOCATION_ATTRIBUTE_VIEW_LIST', default=['slurm_account_name', 'freeipa_group', 'Cloud Account Name', ]) - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +ALLOCATION_ATTRIBUTE_VIEW_LIST = ENV.list( + "ALLOCATION_ATTRIBUTE_VIEW_LIST", + default=[ + "slurm_account_name", + "freeipa_group", + "Cloud Account Name", + ], +) + +# ------------------------------------------------------------------------------ # Enable invoice functionality -#------------------------------------------------------------------------------ -INVOICE_ENABLED = ENV.bool('INVOICE_ENABLED', default=True) +# ------------------------------------------------------------------------------ +INVOICE_ENABLED = ENV.bool("INVOICE_ENABLED", default=True) # Override default 'Pending Payment' status -INVOICE_DEFAULT_STATUS = ENV.str('INVOICE_DEFAULT_STATUS', default='New') +INVOICE_DEFAULT_STATUS = ENV.str("INVOICE_DEFAULT_STATUS", default="New") -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable Open OnDemand integration -#------------------------------------------------------------------------------ -ONDEMAND_URL = ENV.str('ONDEMAND_URL', default=None) +# ------------------------------------------------------------------------------ +ONDEMAND_URL = ENV.str("ONDEMAND_URL", default=None) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Default Strings. Override these in local_settings.py -#------------------------------------------------------------------------------ -LOGIN_FAIL_MESSAGE = ENV.str('LOGIN_FAIL_MESSAGE', '') +# ------------------------------------------------------------------------------ +LOGIN_FAIL_MESSAGE = ENV.str("LOGIN_FAIL_MESSAGE", "") EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL = """ You recently applied for renewal of your account, however, to date you have not entered any publication nor grant info in the ColdFront system. I am reluctant to approve your renewal without understanding why. If there are no relevant publications or grants yet, then please let me know. If there are, then I would appreciate it if you would take the time to enter the data (I have done it myself and it took about 15 minutes). We use this information to help make the case to the university for continued investment in our department and it is therefore important that faculty enter the data when appropriate. Please email xxx-helpexample.com if you need technical assistance. @@ -88,7 +114,34 @@ Phone: (xxx) xxx-xxx """ -ACCOUNT_CREATION_TEXT = '''University faculty can submit a help ticket to request an account. +ACCOUNT_CREATION_TEXT = """University faculty can submit a help ticket to request an account. Please see instructions on our website. Staff, students, and external collaborators must request an account through a university faculty member. -''' +""" + + +# ------------------------------------------------------------------------------ +# Provide institution project code. +# ------------------------------------------------------------------------------ + +PROJECT_CODE = ENV.str("PROJECT_CODE", default=None) +PROJECT_CODE_PADDING = ENV.int("PROJECT_CODE_PADDING", default=None) + +# ------------------------------------------------------------------------------ +# Enable project institution code feature. +# ------------------------------------------------------------------------------ + +PROJECT_INSTITUTION_EMAIL_MAP = ENV.dict("PROJECT_INSTITUTION_EMAIL_MAP", default={}) + +# ------------------------------------------------------------------------------ +# Configure Project fields that project managers can update +# ------------------------------------------------------------------------------ + +PROJECT_UPDATE_FIELDS = ENV.list( + "PROJECT_UPDATE_FIELDS", + default=[ + "title", + "description", + "field_of_science", + ], +) diff --git a/coldfront/config/database.py b/coldfront/config/database.py index 13fcd8c7aa..3de4c60881 100644 --- a/coldfront/config/database.py +++ b/coldfront/config/database.py @@ -1,9 +1,14 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import os + from coldfront.config.env import ENV -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Database settings -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Set this using the DB_URL env variable. Defaults to sqlite. # # Examples: @@ -13,18 +18,13 @@ # # Postgresql: # DB_URL=psql://user:password@127.0.0.1:5432/database -#------------------------------------------------------------------------------ -DATABASES = { - 'default': ENV.db_url( - var='DB_URL', - default='sqlite:///' + os.path.join(os.getcwd(), 'coldfront.db') - ) -} +# ------------------------------------------------------------------------------ +DATABASES = {"default": ENV.db_url(var="DB_URL", default="sqlite:///" + os.path.join(os.getcwd(), "coldfront.db"))} -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Custom Database settings -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # You can also override this manually in local_settings.py, for example: # # NOTE: For mysql you need to: pip install mysqlclient diff --git a/coldfront/config/email.py b/coldfront/config/email.py index aee4d5f0d9..70d2256c14 100644 --- a/coldfront/config/email.py +++ b/coldfront/config/email.py @@ -1,24 +1,37 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.env import ENV -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Email/Notification settings -#------------------------------------------------------------------------------ -EMAIL_ENABLED = ENV.bool('EMAIL_ENABLED', default=False) -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = ENV.str('EMAIL_HOST', default='localhost') -EMAIL_PORT = ENV.int('EMAIL_PORT', default=25) -EMAIL_HOST_USER = ENV.str('EMAIL_HOST_USER', default='') -EMAIL_HOST_PASSWORD = ENV.str('EMAIL_HOST_PASSWORD', default='') -EMAIL_USE_TLS = ENV.bool('EMAIL_USE_TLS', default=False) -EMAIL_TIMEOUT = ENV.int('EMAIL_TIMEOUT', default=3) -EMAIL_SUBJECT_PREFIX = ENV.str('EMAIL_SUBJECT_PREFIX', default='[ColdFront]') -EMAIL_ADMIN_LIST = ENV.list('EMAIL_ADMIN_LIST', default=[]) -EMAIL_SENDER = ENV.str('EMAIL_SENDER', default='') -EMAIL_TICKET_SYSTEM_ADDRESS = ENV.str('EMAIL_TICKET_SYSTEM_ADDRESS', default='') -EMAIL_DIRECTOR_EMAIL_ADDRESS = ENV.str('EMAIL_DIRECTOR_EMAIL_ADDRESS', default='') -EMAIL_PROJECT_REVIEW_CONTACT = ENV.str('EMAIL_PROJECT_REVIEW_CONTACT', default='') -EMAIL_DEVELOPMENT_EMAIL_LIST = ENV.list('EMAIL_DEVELOPMENT_EMAIL_LIST', default=[]) -EMAIL_OPT_OUT_INSTRUCTION_URL = ENV.str('EMAIL_OPT_OUT_INSTRUCTION_URL', default='') -EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS = ENV.list('EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS', cast=int, default=[7, 14, 30]) -EMAIL_SIGNATURE = ENV.str('EMAIL_SIGNATURE', default='', multiline=True) -EMAIL_ADMINS_ON_ALLOCATION_EXPIRE = ENV.bool('EMAIL_ADMINS_ON_ALLOCATION_EXPIRE', default=False) +# ------------------------------------------------------------------------------ +EMAIL_ENABLED = ENV.bool("EMAIL_ENABLED", default=False) +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = ENV.str("EMAIL_HOST", default="localhost") +EMAIL_PORT = ENV.int("EMAIL_PORT", default=25) +EMAIL_HOST_USER = ENV.str("EMAIL_HOST_USER", default="") +EMAIL_HOST_PASSWORD = ENV.str("EMAIL_HOST_PASSWORD", default="") +EMAIL_USE_TLS = ENV.bool("EMAIL_USE_TLS", default=False) +EMAIL_TIMEOUT = ENV.int("EMAIL_TIMEOUT", default=3) +EMAIL_SUBJECT_PREFIX = ENV.str("EMAIL_SUBJECT_PREFIX", default="[ColdFront]") +EMAIL_ADMIN_LIST = ENV.list("EMAIL_ADMIN_LIST", default=[]) +EMAIL_SENDER = ENV.str("EMAIL_SENDER", default="") +EMAIL_TICKET_SYSTEM_ADDRESS = ENV.str("EMAIL_TICKET_SYSTEM_ADDRESS", default="") +EMAIL_DIRECTOR_EMAIL_ADDRESS = ENV.str("EMAIL_DIRECTOR_EMAIL_ADDRESS", default="") +EMAIL_PROJECT_REVIEW_CONTACT = ENV.str("EMAIL_PROJECT_REVIEW_CONTACT", default="") +EMAIL_DEVELOPMENT_EMAIL_LIST = ENV.list("EMAIL_DEVELOPMENT_EMAIL_LIST", default=[]) +EMAIL_OPT_OUT_INSTRUCTION_URL = ENV.str("EMAIL_OPT_OUT_INSTRUCTION_URL", default="") +EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS = ENV.list( + "EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS", cast=int, default=[7, 14, 30] +) +EMAIL_SIGNATURE = ENV.str("EMAIL_SIGNATURE", default="", multiline=True) +EMAIL_ADMINS_ON_ALLOCATION_EXPIRE = ENV.bool("EMAIL_ADMINS_ON_ALLOCATION_EXPIRE", default=False) +EMAIL_ALLOCATION_EULA_REMINDERS = ENV.bool("EMAIL_ALLOCATION_EULA_REMINDERS", default=False) +EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = ENV.bool("EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT", default=False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS = ENV.bool("EMAIL_ALLOCATION_EULA_CONFIRMATIONS", default=False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS = ENV.bool( + "EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS", default=False +) +EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA = ENV.bool("EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA", default=False) diff --git a/coldfront/config/env.py b/coldfront/config/env.py index 226d587ef4..360a96eba8 100644 --- a/coldfront/config/env.py +++ b/coldfront/config/env.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import environ ENV = environ.Env() @@ -5,17 +9,17 @@ # Default paths to environment files env_paths = [ - PROJECT_ROOT.path('.env'), - environ.Path('/etc/coldfront/coldfront.env'), + PROJECT_ROOT.path(".env"), + environ.Path("/etc/coldfront/coldfront.env"), ] -if ENV.str('COLDFRONT_ENV', default='') != '': - env_paths.insert(0, environ.Path(ENV.str('COLDFRONT_ENV'))) +if ENV.str("COLDFRONT_ENV", default="") != "": + env_paths.insert(0, environ.Path(ENV.str("COLDFRONT_ENV"))) # Read in any environment files for e in env_paths: try: - e.file('') - ENV.read_env(e()) + with e.file(""): + ENV.read_env(e()) except FileNotFoundError: pass diff --git a/coldfront/config/logging.py b/coldfront/config/logging.py index 8d533a7766..58055a94f4 100644 --- a/coldfront/config/logging.py +++ b/coldfront/config/logging.py @@ -1,38 +1,44 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib.messages import constants as messages -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # ColdFront logging config -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ MESSAGE_TAGS = { - messages.DEBUG: 'info', - messages.INFO: 'info', - messages.SUCCESS: 'success', - messages.WARNING: 'warning', - messages.ERROR: 'danger', + messages.DEBUG: "info", + messages.INFO: "info", + messages.SUCCESS: "success", + messages.WARNING: "warning", + messages.ERROR: "danger", } LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", }, # 'file': { # 'class': 'logging.FileHandler', # 'filename': '/tmp/debug.log', # }, }, - 'loggers': { - 'django_auth_ldap': { - 'level': 'WARN', + "loggers": { + "django_auth_ldap": { + "level": "WARNING", # 'handlers': ['console', 'file'], - 'handlers': ['console', ], + "handlers": [ + "console", + ], }, - 'django': { - 'handlers': ['console'], - 'level': 'INFO', + "django": { + "handlers": ["console"], + "level": "INFO", }, }, } diff --git a/coldfront/config/plugins/__init__.py b/coldfront/config/plugins/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/config/plugins/__init__.py +++ b/coldfront/config/plugins/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/config/plugins/api.py b/coldfront/config/plugins/api.py new file mode 100644 index 0000000000..cbaab2f58a --- /dev/null +++ b/coldfront/config/plugins/api.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from coldfront.config.base import INSTALLED_APPS + +INSTALLED_APPS += ["django_filters", "rest_framework", "rest_framework.authtoken", "coldfront.plugins.api"] + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.SessionAuthentication", + # only use BasicAuthentication for test purposes + # 'rest_framework.authentication.BasicAuthentication', + ), + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], +} diff --git a/coldfront/config/plugins/auto_compute_allocation.py b/coldfront/config/plugins/auto_compute_allocation.py new file mode 100644 index 0000000000..38b42ac536 --- /dev/null +++ b/coldfront/config/plugins/auto_compute_allocation.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from coldfront.config.base import INSTALLED_APPS +from coldfront.config.env import ENV + +INSTALLED_APPS += [ + "coldfront.plugins.auto_compute_allocation", +] + +AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS = ENV.int("AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS", default=0) +AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING = ENV.int( + "AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING", default=0 +) +AUTO_COMPUTE_ALLOCATION_CORE_HOURS = ENV.int("AUTO_COMPUTE_ALLOCATION_CORE_HOURS", default=0) +AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING = ENV.int("AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING", default=0) +AUTO_COMPUTE_ALLOCATION_END_DELTA = ENV.int("AUTO_COMPUTE_ALLOCATION_END_DELTA", default=365) +AUTO_COMPUTE_ALLOCATION_CHANGEABLE = ENV.bool("AUTO_COMPUTE_ALLOCATION_CHANGEABLE", default=True) +AUTO_COMPUTE_ALLOCATION_LOCKED = ENV.bool("AUTO_COMPUTE_ALLOCATION_LOCKED", default=False) +AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION = ENV.bool("AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION", default=False) +AUTO_COMPUTE_ALLOCATION_CLUSTERS = ENV.tuple("AUTO_COMPUTE_ALLOCATION_CLUSTERS", default=()) +# example auto|Cluster| results in auto|Cluster|CDF0001 where CDF0001 is an example project_code for pk=1 +AUTO_COMPUTE_ALLOCATION_DESCRIPTION = ENV.str("AUTO_COMPUTE_ALLOCATION_DESCRIPTION", default="auto|Cluster|") +# Tuple for slurm_specs attribute - a tuple is required with each element a slurm attr e.g. AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE=('GrpTRESMins=cpu=999;mem=999;gres/gpu=999',) +# NOTE: the semi-colon is required [as an internal delimiter] instead of comma, this gets converted to comma +AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE = ENV.tuple("AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE", default=()) +# Tuple same format as above, see note +AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING = ENV.tuple( + "AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING", default=() +) +AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT = ENV.str( + "AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT", default="" +) +AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT = ENV.str( + "AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT", default="" +) diff --git a/coldfront/config/plugins/freeipa.py b/coldfront/config/plugins/freeipa.py index c38656dac7..51e7ee7ab8 100644 --- a/coldfront/config/plugins/freeipa.py +++ b/coldfront/config/plugins/freeipa.py @@ -1,12 +1,18 @@ -from coldfront.config.base import INSTALLED_APPS, ENV +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from coldfront.config.base import INSTALLED_APPS from coldfront.config.env import ENV INSTALLED_APPS += [ - 'coldfront.plugins.freeipa', + "coldfront.plugins.freeipa", ] -FREEIPA_KTNAME = ENV.str('FREEIPA_KTNAME') -FREEIPA_SERVER = ENV.str('FREEIPA_SERVER') -FREEIPA_USER_SEARCH_BASE = ENV.str('FREEIPA_USER_SEARCH_BASE') +FREEIPA_KTNAME = ENV.str("FREEIPA_KTNAME") +FREEIPA_SERVER = ENV.str("FREEIPA_SERVER") +FREEIPA_USER_SEARCH_BASE = ENV.str("FREEIPA_USER_SEARCH_BASE") FREEIPA_ENABLE_SIGNALS = False -ADDITIONAL_USER_SEARCH_CLASSES = ['coldfront.plugins.freeipa.search.LDAPUserSearch',] +ADDITIONAL_USER_SEARCH_CLASSES = [ + "coldfront.plugins.freeipa.search.LDAPUserSearch", +] diff --git a/coldfront/config/plugins/iquota.py b/coldfront/config/plugins/iquota.py index ca572ff0de..69cc62cfa9 100644 --- a/coldfront/config/plugins/iquota.py +++ b/coldfront/config/plugins/iquota.py @@ -1,11 +1,15 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.base import INSTALLED_APPS from coldfront.config.env import ENV INSTALLED_APPS += [ - 'coldfront.plugins.iquota', + "coldfront.plugins.iquota", ] -IQUOTA_KEYTAB = ENV.str('IQUOTA_KEYTAB') -IQUOTA_CA_CERT = ENV.str('IQUOTA_CA_CERT') -IQUOTA_API_HOST = ENV.str('IQUOTA_API_HOST') -IQUOTA_API_PORT = ENV.str('IQUOTA_API_PORT', default='8080') +IQUOTA_KEYTAB = ENV.str("IQUOTA_KEYTAB") +IQUOTA_CA_CERT = ENV.str("IQUOTA_CA_CERT") +IQUOTA_API_HOST = ENV.str("IQUOTA_API_HOST") +IQUOTA_API_PORT = ENV.str("IQUOTA_API_PORT", default="8080") diff --git a/coldfront/config/plugins/ldap.py b/coldfront/config/plugins/ldap.py index 7833c36322..8203f446ef 100644 --- a/coldfront/config/plugins/ldap.py +++ b/coldfront/config/plugins/ldap.py @@ -1,41 +1,51 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.core.exceptions import ImproperlyConfigured + from coldfront.config.base import AUTHENTICATION_BACKENDS from coldfront.config.env import ENV -from django.core.exceptions import ImproperlyConfigured try: import ldap from django_auth_ldap.config import GroupOfNamesType, LDAPSearch except ImportError: - raise ImproperlyConfigured('Please run: pip install ldap3 django_auth_ldap') + raise ImproperlyConfigured("Please run: pip install ldap3 django_auth_ldap") -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # LDAP user authentication using django-auth-ldap. This will enable LDAP # user/password logins. You can also override this in local_settings.py -#------------------------------------------------------------------------------ -AUTH_COLDFRONT_LDAP_SEARCH_SCOPE = ENV.str('AUTH_COLDFRONT_LDAP_SEARCH_SCOPE', default='ONELEVEL') - -AUTH_LDAP_SERVER_URI = ENV.str('AUTH_LDAP_SERVER_URI') -AUTH_LDAP_USER_SEARCH_BASE = ENV.str('AUTH_LDAP_USER_SEARCH_BASE') -AUTH_LDAP_START_TLS = ENV.bool('AUTH_LDAP_START_TLS', default=True) -AUTH_LDAP_BIND_DN = ENV.str('AUTH_LDAP_BIND_DN', default='') -AUTH_LDAP_BIND_PASSWORD = ENV.str('AUTH_LDAP_BIND_PASSWORD', default='') -AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = ENV.bool('AUTH_LDAP_BIND_AS_AUTHENTICATING_USER', default=False) -AUTH_LDAP_MIRROR_GROUPS = ENV.bool('AUTH_LDAP_MIRROR_GROUPS', default=True) -AUTH_LDAP_GROUP_SEARCH_BASE = ENV.str('AUTH_LDAP_GROUP_SEARCH_BASE') - -if AUTH_COLDFRONT_LDAP_SEARCH_SCOPE == 'SUBTREE': - AUTH_LDAP_USER_SEARCH = LDAPSearch(AUTH_LDAP_USER_SEARCH_BASE, ldap.SCOPE_SUBTREE, '(uid=%(user)s)') - AUTH_LDAP_GROUP_SEARCH = LDAPSearch(AUTH_LDAP_GROUP_SEARCH_BASE, ldap.SCOPE_SUBTREE, '(objectClass=groupOfNames)') +# ------------------------------------------------------------------------------ +AUTH_COLDFRONT_LDAP_SEARCH_SCOPE = ENV.str("AUTH_COLDFRONT_LDAP_SEARCH_SCOPE", default="ONELEVEL") + +AUTH_LDAP_SERVER_URI = ENV.str("AUTH_LDAP_SERVER_URI") +AUTH_LDAP_USER_SEARCH_BASE = ENV.str("AUTH_LDAP_USER_SEARCH_BASE") +AUTH_LDAP_START_TLS = ENV.bool("AUTH_LDAP_START_TLS", default=True) +AUTH_LDAP_BIND_DN = ENV.str("AUTH_LDAP_BIND_DN", default="") +AUTH_LDAP_BIND_PASSWORD = ENV.str("AUTH_LDAP_BIND_PASSWORD", default="") +AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = ENV.bool("AUTH_LDAP_BIND_AS_AUTHENTICATING_USER", default=False) +AUTH_LDAP_MIRROR_GROUPS = ENV.bool("AUTH_LDAP_MIRROR_GROUPS", default=True) +AUTH_LDAP_GROUP_SEARCH_BASE = ENV.str("AUTH_LDAP_GROUP_SEARCH_BASE") + +if AUTH_COLDFRONT_LDAP_SEARCH_SCOPE == "SUBTREE": + AUTH_LDAP_USER_SEARCH = LDAPSearch(AUTH_LDAP_USER_SEARCH_BASE, ldap.SCOPE_SUBTREE, "(uid=%(user)s)") + AUTH_LDAP_GROUP_SEARCH = LDAPSearch(AUTH_LDAP_GROUP_SEARCH_BASE, ldap.SCOPE_SUBTREE, "(objectClass=groupOfNames)") else: - AUTH_LDAP_USER_SEARCH = LDAPSearch(AUTH_LDAP_USER_SEARCH_BASE, ldap.SCOPE_ONELEVEL, '(uid=%(user)s)') - AUTH_LDAP_GROUP_SEARCH = LDAPSearch(AUTH_LDAP_GROUP_SEARCH_BASE, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)') + AUTH_LDAP_USER_SEARCH = LDAPSearch(AUTH_LDAP_USER_SEARCH_BASE, ldap.SCOPE_ONELEVEL, "(uid=%(user)s)") + AUTH_LDAP_GROUP_SEARCH = LDAPSearch(AUTH_LDAP_GROUP_SEARCH_BASE, ldap.SCOPE_ONELEVEL, "(objectClass=groupOfNames)") AUTH_LDAP_GROUP_TYPE = GroupOfNamesType() -AUTH_LDAP_USER_ATTR_MAP = ENV.dict('AUTH_LDAP_USER_ATTR_MAP', default ={ - 'username': 'uid', - 'first_name': 'givenName', - 'last_name': 'sn', - 'email': 'mail', - }) - -AUTHENTICATION_BACKENDS += ['django_auth_ldap.backend.LDAPBackend',] +AUTH_LDAP_USER_ATTR_MAP = ENV.dict( + "AUTH_LDAP_USER_ATTR_MAP", + default={ + "username": "uid", + "first_name": "givenName", + "last_name": "sn", + "email": "mail", + }, +) + +AUTHENTICATION_BACKENDS += [ + "django_auth_ldap.backend.LDAPBackend", +] diff --git a/coldfront/config/plugins/ldap_user_search.py b/coldfront/config/plugins/ldap_user_search.py index 9197bf2649..8ca527d799 100644 --- a/coldfront/config/plugins/ldap_user_search.py +++ b/coldfront/config/plugins/ldap_user_search.py @@ -1,24 +1,30 @@ -from coldfront.config.env import ENV +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import importlib.util + from django.core.exceptions import ImproperlyConfigured -try: - import ldap -except ImportError: - raise ImproperlyConfigured('Please run: pip install ldap3') +from coldfront.config.env import ENV + +if importlib.util.find_spec("ldap") is None: + raise ImproperlyConfigured("Please install required ldap module") # ---------------------------------------------------------------------------- # This enables searching for users via LDAP # ---------------------------------------------------------------------------- -LDAP_USER_SEARCH_SERVER_URI = ENV.str('LDAP_USER_SEARCH_SERVER_URI') -LDAP_USER_SEARCH_BASE = ENV.str('LDAP_USER_SEARCH_BASE') -LDAP_USER_SEARCH_BIND_DN = ENV.str('LDAP_USER_SEARCH_BIND_DN', default=None) -LDAP_USER_SEARCH_BIND_PASSWORD = ENV.str('LDAP_USER_SEARCH_BIND_PASSWORD', default=None) -LDAP_USER_SEARCH_CONNECT_TIMEOUT = ENV.float('LDAP_USER_SEARCH_CONNECT_TIMEOUT', default=2.5) -LDAP_USER_SEARCH_USE_SSL = ENV.bool('LDAP_USER_SEARCH_USE_SSL', default=True) -LDAP_USER_SEARCH_USE_TLS = ENV.bool('LDAP_USER_SEARCH_USE_TLS', default=False) +LDAP_USER_SEARCH_SERVER_URI = ENV.str("LDAP_USER_SEARCH_SERVER_URI") +LDAP_USER_SEARCH_BASE = ENV.str("LDAP_USER_SEARCH_BASE") +LDAP_USER_SEARCH_BIND_DN = ENV.str("LDAP_USER_SEARCH_BIND_DN", default=None) +LDAP_USER_SEARCH_BIND_PASSWORD = ENV.str("LDAP_USER_SEARCH_BIND_PASSWORD", default=None) +LDAP_USER_SEARCH_CONNECT_TIMEOUT = ENV.float("LDAP_USER_SEARCH_CONNECT_TIMEOUT", default=2.5) +LDAP_USER_SEARCH_USE_SSL = ENV.bool("LDAP_USER_SEARCH_USE_SSL", default=True) +LDAP_USER_SEARCH_USE_TLS = ENV.bool("LDAP_USER_SEARCH_USE_TLS", default=False) LDAP_USER_SEARCH_PRIV_KEY_FILE = ENV.str("LDAP_USER_SEARCH_PRIV_KEY_FILE", default=None) LDAP_USER_SEARCH_CERT_FILE = ENV.str("LDAP_USER_SEARCH_CERT_FILE", default=None) LDAP_USER_SEARCH_CACERT_FILE = ENV.str("LDAP_USER_SEARCH_CACERT_FILE", default=None) +LDAP_USER_SEARCH_CERT_VALIDATE_MODE = ENV.str("LDAP_USER_SEARCH_CERT_VALIDATE_MODE", default=None) -ADDITIONAL_USER_SEARCH_CLASSES = ['coldfront.plugins.ldap_user_search.utils.LDAPUserSearch'] +ADDITIONAL_USER_SEARCH_CLASSES = ["coldfront.plugins.ldap_user_search.utils.LDAPUserSearch"] diff --git a/coldfront/config/plugins/openid.py b/coldfront/config/plugins/openid.py index 45971d0cb9..5d54422619 100644 --- a/coldfront/config/plugins/openid.py +++ b/coldfront/config/plugins/openid.py @@ -1,39 +1,44 @@ -from coldfront.config.base import INSTALLED_APPS, MIDDLEWARE, AUTHENTICATION_BACKENDS +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from coldfront.config.base import AUTHENTICATION_BACKENDS, INSTALLED_APPS, MIDDLEWARE from coldfront.config.env import ENV -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable OpenID Connect Authentication Backend -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ INSTALLED_APPS += [ - 'mozilla_django_oidc', + "mozilla_django_oidc", ] -if ENV.bool('PLUGIN_MOKEY', default=False): - #------------------------------------------------------------------------------ +if ENV.bool("PLUGIN_MOKEY", default=False): + # ------------------------------------------------------------------------------ # Enable Mokey/Hydra OpenID Connect Authentication Backend - #------------------------------------------------------------------------------ + # ------------------------------------------------------------------------------ INSTALLED_APPS += [ - 'coldfront.plugins.mokey_oidc', + "coldfront.plugins.mokey_oidc", ] AUTHENTICATION_BACKENDS += [ - 'coldfront.plugins.mokey_oidc.auth.OIDCMokeyAuthenticationBackend', + "coldfront.plugins.mokey_oidc.auth.OIDCMokeyAuthenticationBackend", ] + MOKEY_OIDC_PI_GROUP = ENV.str("MOKEY_OIDC_PI_GROUP") else: AUTHENTICATION_BACKENDS += [ - 'mozilla_django_oidc.auth.OIDCAuthenticationBackend', + "mozilla_django_oidc.auth.OIDCAuthenticationBackend", ] MIDDLEWARE += [ - 'mozilla_django_oidc.middleware.SessionRefresh', + "mozilla_django_oidc.middleware.SessionRefresh", ] -OIDC_OP_JWKS_ENDPOINT = ENV.str('OIDC_OP_JWKS_ENDPOINT') -OIDC_RP_SIGN_ALGO = ENV.str('OIDC_RP_SIGN_ALGO') -OIDC_RP_CLIENT_ID = ENV.str('OIDC_RP_CLIENT_ID') -OIDC_RP_CLIENT_SECRET = ENV.str('OIDC_RP_CLIENT_SECRET') -OIDC_OP_AUTHORIZATION_ENDPOINT = ENV.str('OIDC_OP_AUTHORIZATION_ENDPOINT') -OIDC_OP_TOKEN_ENDPOINT = ENV.str('OIDC_OP_TOKEN_ENDPOINT') -OIDC_OP_USER_ENDPOINT = ENV.str('OIDC_OP_USER_ENDPOINT') -OIDC_VERIFY_SSL = ENV.bool('OIDC_VERIFY_SSL', default=True) -OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS = ENV.int('OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS', default=3600) +OIDC_OP_JWKS_ENDPOINT = ENV.str("OIDC_OP_JWKS_ENDPOINT") +OIDC_RP_SIGN_ALGO = ENV.str("OIDC_RP_SIGN_ALGO") +OIDC_RP_CLIENT_ID = ENV.str("OIDC_RP_CLIENT_ID") +OIDC_RP_CLIENT_SECRET = ENV.str("OIDC_RP_CLIENT_SECRET") +OIDC_OP_AUTHORIZATION_ENDPOINT = ENV.str("OIDC_OP_AUTHORIZATION_ENDPOINT") +OIDC_OP_TOKEN_ENDPOINT = ENV.str("OIDC_OP_TOKEN_ENDPOINT") +OIDC_OP_USER_ENDPOINT = ENV.str("OIDC_OP_USER_ENDPOINT") +OIDC_VERIFY_SSL = ENV.bool("OIDC_VERIFY_SSL", default=True) +OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS = ENV.int("OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS", default=3600) diff --git a/coldfront/config/plugins/project_openldap.py b/coldfront/config/plugins/project_openldap.py new file mode 100644 index 0000000000..5f6d28e6e1 --- /dev/null +++ b/coldfront/config/plugins/project_openldap.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from coldfront.config.base import INSTALLED_APPS +from coldfront.config.env import ENV + +INSTALLED_APPS += [ + "coldfront.plugins.project_openldap", +] + +# Connection URI and bind user +PROJECT_OPENLDAP_SERVER_URI = ENV.str("PROJECT_OPENLDAP_SERVER_URI", default="") +PROJECT_OPENLDAP_BIND_USER = ENV.str("PROJECT_OPENLDAP_BIND_USER", default="") +PROJECT_OPENLDAP_BIND_PASSWORD = ENV.str("PROJECT_OPENLDAP_BIND_PASSWORD", default="") +# Timeout and SSL settings +PROJECT_OPENLDAP_CONNECT_TIMEOUT = ENV.float("PROJECT_OPENLDAP_CONNECT_TIMEOUT", default=2.5) +PROJECT_OPENLDAP_USE_SSL = ENV.bool("PROJECT_OPENLDAP_USE_SSL", default=True) +PROJECT_OPENLDAP_USE_TLS = ENV.bool("PROJECT_OPENLDAP_USE_TLS", default=False) +PROJECT_OPENLDAP_PRIV_KEY_FILE = ENV.str("PROJECT_OPENLDAP_PRIV_KEY_FILE", default=None) +PROJECT_OPENLDAP_CERT_FILE = ENV.str("PROJECT_OPENLDAP_CERT_FILE", default=None) +PROJECT_OPENLDAP_CACERT_FILE = ENV.str("PROJECT_OPENLDAP_CACERT_FILE", default=None) +# OU, GID, Arhive and sync excludes +PROJECT_OPENLDAP_OU = ENV.str("PROJECT_OPENLDAP_OU", default="") # where projects will be stored +PROJECT_OPENLDAP_GID_START = ENV.int( + "PROJECT_OPENLDAP_GID_START" +) # where project gid numbering will start, no default value provided here on purpose, site should define sensible value +PROJECT_OPENLDAP_REMOVE_PROJECT = ENV.bool( + "PROJECT_OPENLDAP_REMOVE_PROJECT", default=True +) # remove projects on archive +PROJECT_OPENLDAP_ARCHIVE_OU = ENV.str( + "PROJECT_OPENLDAP_ARCHIVE_OU", default="" +) # where projects will be stored for archive e.g. ou=archive_projects... +PROJECT_OPENLDAP_EXCLUDE_USERS = ENV.tuple( + "PROJECT_OPENLDAP_EXCLUDE_USERS", default=("coldfront",) +) # never try to add these users to OpenLDAP - used by syncer script +# OpenLDAP description field for project +PROJECT_OPENLDAP_DESCRIPTION_TITLE_LENGTH = ENV.int( + "PROJECT_OPENLDAP_DESCRIPTION_TITLE_LENGTH", default=100 +) # control the length of project title component from CF which is inserted into the OpenLDAP description field for each OpenLDAP project and is potentially truncated down diff --git a/coldfront/config/plugins/slurm.py b/coldfront/config/plugins/slurm.py index 56f690c9e8..158384bfe9 100644 --- a/coldfront/config/plugins/slurm.py +++ b/coldfront/config/plugins/slurm.py @@ -1,11 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.base import INSTALLED_APPS from coldfront.config.env import ENV INSTALLED_APPS += [ - 'coldfront.plugins.slurm', + "coldfront.plugins.slurm", ] -SLURM_SACCTMGR_PATH = ENV.str('SLURM_SACCTMGR_PATH', default='/usr/bin/sacctmgr') -SLURM_NOOP = ENV.bool('SLURM_NOOP', False) -SLURM_IGNORE_USERS = ENV.list('SLURM_IGNORE_USERS', default=['root']) -SLURM_IGNORE_ACCOUNTS = ENV.list('SLURM_IGNORE_ACCOUNTS', default=[]) +SLURM_SACCTMGR_PATH = ENV.str("SLURM_SACCTMGR_PATH", default="/usr/bin/sacctmgr") +SLURM_NOOP = ENV.bool("SLURM_NOOP", False) +SLURM_IGNORE_USERS = ENV.list("SLURM_IGNORE_USERS", default=["root"]) +SLURM_IGNORE_ACCOUNTS = ENV.list("SLURM_IGNORE_ACCOUNTS", default=[]) +SLURM_SUBMISSION_INFO = ENV.list("SLURM_SUBMISSION_INFO", default=["account"]) +SLURM_DISPLAY_SHORT_OPTION_NAMES = ENV.bool("SLURM_DISPLAY_SHORT_OPTION_NAMES", default=False) +SLURM_SHORT_OPTION_NAMES = ENV.dict( + "SLURM_SHORT_OPTION_NAMES", + default={ + "qos": "q", + "account": "A", + "clusters": "M", + "partition": "p", + }, +) diff --git a/coldfront/config/plugins/system_monitor.py b/coldfront/config/plugins/system_monitor.py index d2db6552b7..73c90e8db1 100644 --- a/coldfront/config/plugins/system_monitor.py +++ b/coldfront/config/plugins/system_monitor.py @@ -1,11 +1,18 @@ -from coldfront.config.base import INSTALLED_APPS +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from coldfront.config.base import INSTALLED_APPS, SETTINGS_EXPORT from coldfront.config.env import ENV -INSTALLED_APPS += [ - 'coldfront.plugins.system_monitor' -] +INSTALLED_APPS += ["coldfront.plugins.system_monitor"] -SYSTEM_MONITOR_PANEL_TITLE = ENV.str('SYSMON_TITLE', default='HPC Cluster Status') -SYSTEM_MONITOR_ENDPOINT = ENV.str('SYSMON_ENDPOINT') -SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK = ENV.str('SYSMON_LINK') -SYSTEM_MONITOR_DISPLAY_XDMOD_LINK = ENV.str('SYSMON_XDMOD_LINK') +SYSTEM_MONITOR_PANEL_TITLE = ENV.str("SYSMON_TITLE", default="HPC Cluster Status") +SYSTEM_MONITOR_ENDPOINT = ENV.str("SYSMON_ENDPOINT") +SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK = ENV.str("SYSMON_LINK", default=None) +SYSTEM_MONITOR_DISPLAY_XDMOD_LINK = ENV.str("SYSMON_XDMOD_LINK", default=None) + +SETTINGS_EXPORT += [ + "SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK", + "SYSTEM_MONITOR_DISPLAY_XDMOD_LINK", +] diff --git a/coldfront/config/plugins/xdmod.py b/coldfront/config/plugins/xdmod.py index f5931dc651..943687a4f6 100644 --- a/coldfront/config/plugins/xdmod.py +++ b/coldfront/config/plugins/xdmod.py @@ -1,11 +1,15 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.base import INSTALLED_APPS from coldfront.config.env import ENV -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable XDMoD support -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ INSTALLED_APPS += [ - 'coldfront.plugins.xdmod', + "coldfront.plugins.xdmod", ] -XDMOD_API_URL = ENV.str('XDMOD_API_URL') +XDMOD_API_URL = ENV.str("XDMOD_API_URL") diff --git a/coldfront/config/settings.py b/coldfront/config/settings.py index 7ab3ccfc10..8a892997d1 100644 --- a/coldfront/config/settings.py +++ b/coldfront/config/settings.py @@ -1,27 +1,35 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import environ -from split_settings.tools import optional, include +from split_settings.tools import include, optional + from coldfront.config.env import ENV, PROJECT_ROOT # ColdFront split settings coldfront_configs = [ - 'base.py', - 'database.py', - 'auth.py', - 'logging.py', - 'core.py', - 'email.py', + "base.py", + "database.py", + "auth.py", + "logging.py", + "core.py", + "email.py", ] # ColdFront plugin settings plugin_configs = { - 'PLUGIN_SLURM': 'plugins/slurm.py', - 'PLUGIN_IQUOTA': 'plugins/iquota.py', - 'PLUGIN_FREEIPA': 'plugins/freeipa.py', - 'PLUGIN_SYSMON': 'plugins/system_monitor.py', - 'PLUGIN_XDMOD': 'plugins/xdmod.py', - 'PLUGIN_AUTH_OIDC': 'plugins/openid.py', - 'PLUGIN_AUTH_LDAP': 'plugins/ldap.py', - 'PLUGIN_LDAP_USER_SEARCH': 'plugins/ldap_user_search.py', + "PLUGIN_SLURM": "plugins/slurm.py", + "PLUGIN_IQUOTA": "plugins/iquota.py", + "PLUGIN_FREEIPA": "plugins/freeipa.py", + "PLUGIN_SYSMON": "plugins/system_monitor.py", + "PLUGIN_XDMOD": "plugins/xdmod.py", + "PLUGIN_AUTH_OIDC": "plugins/openid.py", + "PLUGIN_AUTH_LDAP": "plugins/ldap.py", + "PLUGIN_LDAP_USER_SEARCH": "plugins/ldap_user_search.py", + "PLUGIN_API": "plugins/api.py", + "PLUGIN_AUTO_COMPUTE_ALLOCATION": "plugins/auto_compute_allocation.py", + "PLUGIN_PROJECT_OPENLDAP": "plugins/project_openldap.py", } # This allows plugins to be enabled via environment variables. Can alternatively @@ -33,18 +41,16 @@ # Local settings overrides local_configs = [ # Local settings relative to coldfront.config package - 'local_settings.py', - + "local_settings.py", # System wide settings for production deployments - '/etc/coldfront/local_settings.py', - + "/etc/coldfront/local_settings.py", # Local settings relative to coldfront project root - PROJECT_ROOT('local_settings.py') + PROJECT_ROOT("local_settings.py"), ] -if ENV.str('COLDFRONT_CONFIG', default='') != '': +if ENV.str("COLDFRONT_CONFIG", default="") != "": # Local settings from path specified via environment variable - local_configs.append(environ.Path(ENV.str('COLDFRONT_CONFIG'))()) + local_configs.append(environ.Path(ENV.str("COLDFRONT_CONFIG"))()) for lc in local_configs: coldfront_configs.append(optional(lc)) diff --git a/coldfront/config/urls.py b/coldfront/config/urls.py index cabf338dc8..30b171c4fe 100644 --- a/coldfront/config/urls.py +++ b/coldfront/config/urls.py @@ -1,43 +1,86 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + """ ColdFront URL Configuration """ + +import environ +import split_settings from django.conf import settings from django.contrib import admin +from django.core import serializers +from django.http import HttpResponse from django.urls import include, path from django.views.generic import TemplateView import coldfront.core.portal.views as portal_views +from coldfront.config.env import ENV, PROJECT_ROOT -admin.site.site_header = 'ColdFront Administration' -admin.site.site_title = 'ColdFront Administration' +admin.site.site_header = "ColdFront Administration" +admin.site.site_title = "ColdFront Administration" urlpatterns = [ - path('admin/', admin.site.urls), - path('robots.txt', TemplateView.as_view(template_name='robots.txt', content_type='text/plain'), name="robots"), - path('', portal_views.home, name='home'), - path('center-summary', portal_views.center_summary, name='center-summary'), - path('allocation-summary', portal_views.allocation_summary, name='allocation-summary'), - path('allocation-by-fos', portal_views.allocation_by_fos, name='allocation-by-fos'), - path('user/', include('coldfront.core.user.urls')), - path('project/', include('coldfront.core.project.urls')), - path('allocation/', include('coldfront.core.allocation.urls')), - path('resource/', include('coldfront.core.resource.urls')), + path("admin/", admin.site.urls), + path("robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain"), name="robots"), + path("", portal_views.home, name="home"), + path("center-summary", portal_views.center_summary, name="center-summary"), + path("allocation-summary", portal_views.allocation_summary, name="allocation-summary"), + path("allocation-by-fos", portal_views.allocation_by_fos, name="allocation-by-fos"), + path("user/", include("coldfront.core.user.urls")), + path("portal/", include("coldfront.core.portal.urls")), + path("project/", include("coldfront.core.project.urls")), + path("allocation/", include("coldfront.core.allocation.urls")), + path("resource/", include("coldfront.core.resource.urls")), ] if settings.GRANT_ENABLE: - urlpatterns.append(path('grant/', include('coldfront.core.grant.urls'))) + urlpatterns.append(path("grant/", include("coldfront.core.grant.urls"))) if settings.PUBLICATION_ENABLE: - urlpatterns.append(path('publication/', include('coldfront.core.publication.urls'))) + urlpatterns.append(path("publication/", include("coldfront.core.publication.urls"))) if settings.RESEARCH_OUTPUT_ENABLE: - urlpatterns.append(path('research-output/', include('coldfront.core.research_output.urls'))) + urlpatterns.append(path("research-output/", include("coldfront.core.research_output.urls"))) + +if "coldfront.plugins.api" in settings.INSTALLED_APPS: + urlpatterns.append(path("api/", include("coldfront.plugins.api.urls"))) + +if "coldfront.plugins.iquota" in settings.INSTALLED_APPS: + urlpatterns.append(path("iquota/", include("coldfront.plugins.iquota.urls"))) + +if "mozilla_django_oidc" in settings.INSTALLED_APPS: + urlpatterns.append(path("oidc/", include("mozilla_django_oidc.urls"))) + +if "coldfront.plugins.slurm" in settings.INSTALLED_APPS: + urlpatterns.append(path("slurm/", include("coldfront.plugins.slurm.urls"))) -if 'coldfront.plugins.iquota' in settings.INSTALLED_APPS: - urlpatterns.append(path('iquota/', include('coldfront.plugins.iquota.urls'))) +if "django_su.backends.SuBackend" in settings.AUTHENTICATION_BACKENDS: + urlpatterns.append(path("su/", include("django_su.urls"))) + + +def export_as_json(modeladmin, request, queryset): + response = HttpResponse(content_type="application/json") + serializers.serialize("json", queryset, stream=response) + return response + + +admin.site.add_action(export_as_json, "export_as_json") + +# Local urls overrides +local_urls = [ + # Local urls relative to coldfront.config package + "local_urls.py", + # System wide urls for production deployments + "/etc/coldfront/local_urls.py", + # Local urls relative to coldfront project root + PROJECT_ROOT("local_urls.py"), +] -if 'mozilla_django_oidc' in settings.INSTALLED_APPS: - urlpatterns.append(path('oidc/', include('mozilla_django_oidc.urls'))) +if ENV.str("COLDFRONT_URLS", default="") != "": + # Local urls from path specified via environment variable + local_urls.append(environ.Path(ENV.str("COLDFRONT_URLS"))()) -if 'django_su.backends.SuBackend' in settings.AUTHENTICATION_BACKENDS: - urlpatterns.append(path('su/', include('django_su.urls'))) +for lu in local_urls: + split_settings.tools.include(split_settings.tools.optional(lu)) diff --git a/coldfront/config/wsgi.py b/coldfront/config/wsgi.py index f4cdb2c78a..3caedadd48 100644 --- a/coldfront/config/wsgi.py +++ b/coldfront/config/wsgi.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + """ WSGI config for ColdFront project. diff --git a/coldfront/core/__init__.py b/coldfront/core/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/__init__.py +++ b/coldfront/core/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/allocation/__init__.py b/coldfront/core/allocation/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/allocation/__init__.py +++ b/coldfront/core/allocation/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/allocation/admin.py b/coldfront/core/allocation/admin.py index 729c2faad7..738e629fdc 100644 --- a/coldfront/core/allocation/admin.py +++ b/coldfront/core/allocation/admin.py @@ -1,74 +1,119 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import textwrap from django.contrib import admin from django.utils.translation import gettext_lazy as _ from simple_history.admin import SimpleHistoryAdmin -from coldfront.core.allocation.models import (Allocation, AllocationAccount, - AllocationAdminNote, - AllocationAttribute, - AllocationAttributeType, - AllocationAttributeUsage, - AllocationChangeRequest, - AllocationAttributeChangeRequest, - AllocationStatusChoice, - AllocationChangeStatusChoice, - AllocationUser, - AllocationUserNote, - AllocationUserStatusChoice, - AttributeType) +from coldfront.core.allocation.models import ( + Allocation, + AllocationAccount, + AllocationAdminNote, + AllocationAttribute, + AllocationAttributeChangeRequest, + AllocationAttributeType, + AllocationAttributeUsage, + AllocationChangeRequest, + AllocationChangeStatusChoice, + AllocationStatusChoice, + AllocationUser, + AllocationUserNote, + AllocationUserStatusChoice, + AttributeType, +) @admin.register(AllocationStatusChoice) class AllocationStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ("name",) class AllocationUserInline(admin.TabularInline): model = AllocationUser extra = 0 - fields = ('user', 'status', ) - raw_id_fields = ('user', ) + fields = ( + "user", + "status", + ) + raw_id_fields = ("user",) class AllocationAttributeInline(admin.TabularInline): model = AllocationAttribute extra = 0 - fields = ('allocation_attribute_type', 'value',) + fields = ( + "allocation_attribute_type", + "value", + ) class AllocationAdminNoteInline(admin.TabularInline): model = AllocationAdminNote extra = 0 - fields = ('note', 'author', 'created'), - readonly_fields = ('author', 'created') + fields = (("note", "author", "created"),) + readonly_fields = ("author", "created") class AllocationUserNoteInline(admin.TabularInline): model = AllocationUserNote extra = 0 - fields = ('note', 'author', 'created'), - readonly_fields = ('author', 'created') + fields = (("note", "author", "created"),) + readonly_fields = ("author", "created") @admin.register(Allocation) class AllocationAdmin(SimpleHistoryAdmin): readonly_fields_change = ( - 'project', 'justification', 'created', 'modified',) - fields_change = ('project', 'resources', 'quantity', 'justification', - 'status', 'start_date', 'end_date', 'description', 'created', 'modified', 'is_locked', 'is_changeable') - list_display = ('pk', 'project_title', 'project_pi', 'resource', 'quantity', - 'justification', 'start_date', 'end_date', 'status', 'created', 'modified', ) - inlines = [AllocationUserInline, - AllocationAttributeInline, - AllocationAdminNoteInline, - AllocationUserNoteInline] - list_filter = ('resources__resource_type__name', - 'status', 'resources__name', 'is_locked') - search_fields = ['project__pi__username', 'project__pi__first_name', 'project__pi__last_name', 'resources__name', - 'allocationuser__user__first_name', 'allocationuser__user__last_name', 'allocationuser__user__username'] - filter_horizontal = ['resources', ] - raw_id_fields = ('project',) + "project", + "justification", + "created", + "modified", + ) + fields_change = ( + "project", + "resources", + "quantity", + "justification", + "status", + "start_date", + "end_date", + "description", + "created", + "modified", + "is_locked", + "is_changeable", + ) + list_display = ( + "pk", + "project_title", + "project_pi", + "resource", + "quantity", + "justification", + "start_date", + "end_date", + "status", + "created", + "modified", + ) + inlines = [AllocationUserInline, AllocationAttributeInline, AllocationAdminNoteInline, AllocationUserNoteInline] + list_filter = ("resources__resource_type__name", "status", "resources__name", "is_locked") + search_fields = [ + "project__pi__username", + "project__pi__first_name", + "project__pi__last_name", + "resources__name", + "allocationuser__user__first_name", + "allocationuser__user__last_name", + "allocationuser__user__username", + ] + filter_horizontal = [ + "resources", + ] + raw_id_fields = ("project",) def resource(self, obj): return obj.get_parent_resource @@ -111,12 +156,21 @@ def save_formset(self, request, form, formset, change): @admin.register(AttributeType) class AttributeTypeAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ("name",) @admin.register(AllocationAttributeType) class AllocationAttributeTypeAdmin(admin.ModelAdmin): - list_display = ('pk', 'name', 'attribute_type', 'has_usage', 'is_private') + list_display = ( + "pk", + "name", + "attribute_type", + "has_usage", + "is_required", + "is_unique", + "is_private", + "is_changeable", + ) class AllocationAttributeUsageInline(admin.TabularInline): @@ -125,59 +179,74 @@ class AllocationAttributeUsageInline(admin.TabularInline): class UsageValueFilter(admin.SimpleListFilter): - title = _('value') + title = _("value") - parameter_name = 'value' + parameter_name = "value" def lookups(self, request, model_admin): return ( - ('>=0', _('Greater than or equal to 0')), - ('>10', _('Greater than 10')), - ('>100', _('Greater than 100')), - ('>1000', _('Greater than 1000')), - ('>10000', _('Greater than 10000')), + (">=0", _("Greater than or equal to 0")), + (">10", _("Greater than 10")), + (">100", _("Greater than 100")), + (">1000", _("Greater than 1000")), + (">10000", _("Greater than 10000")), ) def queryset(self, request, queryset): - - if self.value() == '>=0': + if self.value() == ">=0": return queryset.filter(allocationattributeusage__value__gte=0) - if self.value() == '>10': + if self.value() == ">10": return queryset.filter(allocationattributeusage__value__gte=10) - if self.value() == '>100': + if self.value() == ">100": return queryset.filter(allocationattributeusage__value__gte=100) - if self.value() == '>1000': + if self.value() == ">1000": return queryset.filter(allocationattributeusage__value__gte=1000) @admin.register(AllocationAttribute) class AllocationAttributeAdmin(SimpleHistoryAdmin): - readonly_fields_change = ( - 'allocation', 'allocation_attribute_type', 'created', 'modified', 'project_title') - fields_change = ('project_title', 'allocation', - 'allocation_attribute_type', 'value', 'created', 'modified',) - list_display = ('pk', 'project', 'pi', 'resource', 'allocation_status', - 'allocation_attribute_type', 'value', 'usage', 'created', 'modified',) - inlines = [AllocationAttributeUsageInline, ] - list_filter = (UsageValueFilter, 'allocation_attribute_type', - 'allocation__status', 'allocation__resources') + readonly_fields_change = ("allocation", "allocation_attribute_type", "created", "modified", "project_title") + fields_change = ( + "project_title", + "allocation", + "allocation_attribute_type", + "value", + "created", + "modified", + ) + list_display = ( + "pk", + "project", + "pi", + "resource", + "allocation_status", + "allocation_attribute_type", + "value", + "usage", + "created", + "modified", + ) + inlines = [ + AllocationAttributeUsageInline, + ] + list_filter = (UsageValueFilter, "allocation_attribute_type", "allocation__status", "allocation__resources") search_fields = ( - 'allocation__project__pi__first_name', - 'allocation__project__pi__last_name', - 'allocation__project__pi__username', - 'allocation__allocationuser__user__first_name', - 'allocation__allocationuser__user__last_name', - 'allocation__allocationuser__user__username', + "allocation__project__pi__first_name", + "allocation__project__pi__last_name", + "allocation__project__pi__username", + "allocation__allocationuser__user__first_name", + "allocation__allocationuser__user__last_name", + "allocation__allocationuser__user__username", ) def usage(self, obj): - if hasattr(obj, 'allocationattributeusage'): + if hasattr(obj, "allocationattributeusage"): return obj.allocationattributeusage.value else: - return 'N/A' + return "N/A" def resource(self, obj): return obj.allocation.get_parent_resource @@ -186,7 +255,11 @@ def allocation_status(self, obj): return obj.allocation.status def pi(self, obj): - return '{} {} ({})'.format(obj.allocation.project.pi.first_name, obj.allocation.project.pi.last_name, obj.allocation.project.pi.username) + return "{} {} ({})".format( + obj.allocation.project.pi.first_name, + obj.allocation.project.pi.last_name, + obj.allocation.project.pi.username, + ) def project(self, obj): return textwrap.shorten(obj.allocation.project.title, width=50) @@ -217,29 +290,56 @@ def get_inline_instances(self, request, obj=None): @admin.register(AllocationUserStatusChoice) class AllocationUserStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('name',) + list_display = ("name",) @admin.register(AllocationUser) class AllocationUserAdmin(SimpleHistoryAdmin): - readonly_fields_change = ('allocation', 'user', - 'resource', 'created', 'modified',) - fields_change = ('allocation', 'user', 'status', 'created', 'modified',) - list_display = ('pk', 'project', 'project_pi', 'resource', 'allocation_status', - 'user_info', 'status', 'created', 'modified',) - list_filter = ('status', 'allocation__status', 'allocation__resources',) + readonly_fields_change = ( + "allocation", + "user", + "resource", + "created", + "modified", + ) + fields_change = ( + "allocation", + "user", + "status", + "created", + "modified", + ) + list_display = ( + "pk", + "project", + "project_pi", + "resource", + "allocation_status", + "user_info", + "status", + "created", + "modified", + ) + list_filter = ( + "status", + "allocation__status", + "allocation__resources", + ) search_fields = ( - 'user__first_name', - 'user__last_name', - 'user__username', + "user__first_name", + "user__last_name", + "user__username", + ) + raw_id_fields = ( + "allocation", + "user", ) - raw_id_fields = ('allocation', 'user', ) def allocation_status(self, obj): return obj.allocation.status def user_info(self, obj): - return '{} {} ({})'.format(obj.user.first_name, obj.user.last_name, obj.user.username) + return "{} {} ({})".format(obj.user.first_name, obj.user.last_name, obj.user.username) def resource(self, obj): return obj.allocation.resources.first() @@ -270,24 +370,17 @@ def get_inline_instances(self, request, obj=None): else: return super().get_inline_instances(request) + @admin.action(description="Set Selected User's Status To Active") def set_active(self, request, queryset): - queryset.update( - status=AllocationUserStatusChoice.objects.get(name='Active')) + queryset.update(status=AllocationUserStatusChoice.objects.get(name="Active")) + @admin.action(description="Set Selected User's Status To Denied") def set_denied(self, request, queryset): - queryset.update( - status=AllocationUserStatusChoice.objects.get(name='Denied')) + queryset.update(status=AllocationUserStatusChoice.objects.get(name="Denied")) + @admin.action(description="Set Selected User's Status To Removed") def set_removed(self, request, queryset): - - queryset.update( - status=AllocationUserStatusChoice.objects.get(name='Removed')) - - set_active.short_description = "Set Selected User's Status To Active" - - set_denied.short_description = "Set Selected User's Status To Denied" - - set_removed.short_description = "Set Selected User's Status To Removed" + queryset.update(status=AllocationUserStatusChoice.objects.get(name="Removed")) actions = [ set_active, @@ -297,41 +390,51 @@ def set_removed(self, request, queryset): class ValueFilter(admin.SimpleListFilter): - title = _('value') + title = _("value") - parameter_name = 'value' + parameter_name = "value" def lookups(self, request, model_admin): return ( - ('>0', _('Greater than > 0')), - ('>10', _('Greater than > 10')), - ('>100', _('Greater than > 100')), - ('>1000', _('Greater than > 1000')), + (">0", _("Greater than > 0")), + (">10", _("Greater than > 10")), + (">100", _("Greater than > 100")), + (">1000", _("Greater than > 1000")), ) def queryset(self, request, queryset): - - if self.value() == '>0': + if self.value() == ">0": return queryset.filter(value__gt=0) - if self.value() == '>10': + if self.value() == ">10": return queryset.filter(value__gt=10) - if self.value() == '>100': + if self.value() == ">100": return queryset.filter(value__gt=100) - if self.value() == '>1000': + if self.value() == ">1000": return queryset.filter(value__gt=1000) @admin.register(AllocationAttributeUsage) class AllocationAttributeUsageAdmin(SimpleHistoryAdmin): - list_display = ('allocation_attribute', 'project', - 'project_pi', 'resource', 'value',) - readonly_fields = ('allocation_attribute',) - fields = ('allocation_attribute', 'value',) - list_filter = ('allocation_attribute__allocation_attribute_type', - 'allocation_attribute__allocation__resources', ValueFilter, ) + list_display = ( + "allocation_attribute", + "project", + "project_pi", + "resource", + "value", + ) + readonly_fields = ("allocation_attribute",) + fields = ( + "allocation_attribute", + "value", + ) + list_filter = ( + "allocation_attribute__allocation_attribute_type", + "allocation_attribute__allocation__resources", + ValueFilter, + ) def resource(self, obj): return obj.allocation_attribute.allocation.resources.first().name @@ -345,20 +448,34 @@ def project_pi(self, obj): @admin.register(AllocationAccount) class AllocationAccountAdmin(SimpleHistoryAdmin): - list_display = ('name', 'user', ) + list_display = ( + "name", + "user", + ) @admin.register(AllocationChangeStatusChoice) class AllocationChangeStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ("name",) @admin.register(AllocationChangeRequest) class AllocationChangeRequestAdmin(admin.ModelAdmin): - list_display = ('pk', 'allocation', 'status', 'end_date_extension', 'justification', 'notes', ) + list_display = ( + "pk", + "allocation", + "status", + "end_date_extension", + "justification", + "notes", + ) @admin.register(AllocationAttributeChangeRequest) -class AllocationChangeStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('pk', 'allocation_change_request', 'allocation_attribute', 'new_value', ) - +class AllocationAttributeChangeRequestAdmin(admin.ModelAdmin): + list_display = ( + "pk", + "allocation_change_request", + "allocation_attribute", + "new_value", + ) diff --git a/coldfront/core/allocation/apps.py b/coldfront/core/allocation/apps.py index ba463b89e4..480892b636 100644 --- a/coldfront/core/allocation/apps.py +++ b/coldfront/core/allocation/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class AllocationConfig(AppConfig): - name = 'coldfront.core.allocation' + name = "coldfront.core.allocation" diff --git a/coldfront/core/allocation/forms.py b/coldfront/core/allocation/forms.py index 9700244779..a5ff16947e 100644 --- a/coldfront/core/allocation/forms.py +++ b/coldfront/core/allocation/forms.py @@ -1,73 +1,144 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import forms +from django.contrib.auth import get_user_model from django.db.models.functions import Lower -from django.shortcuts import get_object_or_404 - -from coldfront.core.allocation.models import (Allocation, AllocationAccount, - AllocationAttributeType, - AllocationAttribute, - AllocationStatusChoice) +from django.forms import ValidationError + +from coldfront.core.allocation.models import ( + Allocation, + AllocationAccount, + AllocationAttribute, + AllocationAttributeType, + AllocationStatusChoice, +) from coldfront.core.allocation.utils import get_user_resources from coldfront.core.project.models import Project from coldfront.core.resource.models import Resource, ResourceType +from coldfront.core.user.forms import UserModelMultipleChoiceField from coldfront.core.utils.common import import_from_settings -ALLOCATION_ACCOUNT_ENABLED = import_from_settings( - 'ALLOCATION_ACCOUNT_ENABLED', False) -ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = import_from_settings( - 'ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS', []) +ALLOCATION_ACCOUNT_ENABLED = import_from_settings("ALLOCATION_ACCOUNT_ENABLED", False) +ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = import_from_settings("ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS", []) +ALLOCATION_ACCOUNT_MAPPING = import_from_settings("ALLOCATION_ACCOUNT_MAPPING", {}) +ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT = import_from_settings( + "ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT", True +) +INVOICE_ENABLED = import_from_settings("INVOICE_ENABLED", False) +if INVOICE_ENABLED: + INVOICE_DEFAULT_STATUS = import_from_settings("INVOICE_DEFAULT_STATUS", "Pending Payment") -class AllocationForm(forms.Form): - resource = forms.ModelChoiceField(queryset=None, empty_label=None) - justification = forms.CharField(widget=forms.Textarea) - quantity = forms.IntegerField(required=True) - users = forms.MultipleChoiceField( - widget=forms.CheckboxSelectMultiple, required=False) - allocation_account = forms.ChoiceField(required=False) +class AllocationForm(forms.ModelForm): + class Meta: + model = Allocation + fields = [ + "resource", + "justification", + "quantity", + "users", + "project", + "is_changeable", + "allocation_account", + ] + help_texts = { + "justification": "
Justification for requesting this allocation.", + "users": "
Select users in your project to add to this allocation.", + } + widgets = { + "status": forms.HiddenInput(), + "project": forms.HiddenInput(), + "is_changeable": forms.HiddenInput(), + } - def __init__(self, request_user, project_pk, *args, **kwargs): + resource = forms.ModelChoiceField(queryset=None, empty_label=None) + users = UserModelMultipleChoiceField(queryset=None, required=False) + allocation_account = forms.ModelChoiceField(queryset=None, required=False) + + def __init__(self, request_user, project_pk, *args, **kwargs): + project_obj = Project.objects.get(pk=project_pk) + # Set default initial values + initial = { + "quantity": 1, + "is_changeable": ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT, + "project": project_obj, + } + if kwargs["initial"] is not None: + initial.update(kwargs["initial"]) + kwargs["initial"] = initial super().__init__(*args, **kwargs) - project_obj = get_object_or_404(Project, pk=project_pk) - self.fields['resource'].queryset = get_user_resources(request_user).order_by(Lower("name")) - self.fields['quantity'].initial = 1 - user_query_set = project_obj.projectuser_set.select_related('user').filter( - status__name__in=['Active', ]).order_by("user__username") - user_query_set = user_query_set.exclude(user=project_obj.pi) - if user_query_set: - self.fields['users'].choices = ((user.user.username, "%s %s (%s)" % ( - user.user.first_name, user.user.last_name, user.user.username)) for user in user_query_set) - self.fields['users'].help_text = '
Select users in your project to add to this allocation.' - else: - self.fields['users'].widget = forms.HiddenInput() + self.fields["resource"].queryset = get_user_resources(request_user).order_by(Lower("name")) + self.fields["users"].queryset = ( + get_user_model() + .objects.filter(projectuser__project=project_obj, projectuser__status__name="Active") + .order_by("username") + .exclude(pk=project_obj.pi.pk) + ) + if not self.fields["users"].queryset: + self.fields["users"].widget = forms.HiddenInput() + + # Set allocation_account choices if ALLOCATION_ACCOUNT_ENABLED: - allocation_accounts = AllocationAccount.objects.filter( - user=request_user) - if allocation_accounts: - self.fields['allocation_account'].choices = (((account.name, account.name)) - for account in allocation_accounts) + self.fields["allocation_account"].queryset = AllocationAccount.objects.filter(user=request_user) + if not self.fields["allocation_account"].queryset: + self.fields["allocation_account"].widget = forms.HiddenInput() + else: + self.fields["allocation_account"].widget = forms.HiddenInput() - self.fields['allocation_account'].help_text = '
Select account name to associate with resource. Click here to create an account name!' + def clean(self): + form_data = super().clean() + project_obj = form_data.get("project") + resource_obj = form_data.get("resource") + allocation_account = form_data.get("allocation_account", None) + + # Ensure user has account name if ALLOCATION_ACCOUNT_ENABLED + if ( + ALLOCATION_ACCOUNT_ENABLED + and resource_obj.name in ALLOCATION_ACCOUNT_MAPPING + and AllocationAttributeType.objects.filter(name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name]).exists() + and not allocation_account + ): + raise ValidationError( + 'You need to create an account name. Create it by clicking the link under the "Allocation account" field.', + code="user_has_no_account_name", + ) + + # Ensure this allocaiton wouldn't exceed the limit + allocation_limit = resource_obj.get_attribute("allocation_limit", typed=True) + if allocation_limit: + allocation_count = project_obj.allocation_set.filter( + resources=resource_obj, + status__name__in=["Active", "New", "Renewal Requested", "Paid", "Payment Pending", "Payment Requested"], + ).count() + if allocation_count >= allocation_limit: + raise ValidationError( + "Your project is at the allocation limit allowed for this resource.", + code="reached_allocation_limit", + ) + + # Set allocation status + if INVOICE_ENABLED and resource_obj.requires_payment: + allocation_status_name = INVOICE_DEFAULT_STATUS else: - self.fields['allocation_account'].widget = forms.HiddenInput() + allocation_status_name = "New" + form_data["status"] = AllocationStatusChoice.objects.get(name=allocation_status_name) + self.instance.status = form_data["status"] - self.fields['justification'].help_text = '
Justification for requesting this allocation.' + return form_data class AllocationUpdateForm(forms.Form): status = forms.ModelChoiceField( - queryset=AllocationStatusChoice.objects.all().order_by(Lower("name")), empty_label=None) + queryset=AllocationStatusChoice.objects.all().order_by(Lower("name")), empty_label=None + ) start_date = forms.DateField( - label='Start Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) - end_date = forms.DateField( - label='End Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) - description = forms.CharField(max_length=512, - label='Description', - required=False) + label="Start Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) + end_date = forms.DateField(label="End Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False) + description = forms.CharField(max_length=512, label="Description", required=False) is_locked = forms.BooleanField(required=False) is_changeable = forms.BooleanField(required=False) @@ -77,14 +148,16 @@ def clean(self): end_date = cleaned_data.get("end_date") if start_date and end_date and end_date < start_date: - raise forms.ValidationError( - 'End date cannot be less than start date' - ) + raise forms.ValidationError("End date cannot be less than start date") class AllocationInvoiceUpdateForm(forms.Form): - status = forms.ModelChoiceField(queryset=AllocationStatusChoice.objects.filter(name__in=[ - 'Payment Pending', 'Payment Requested', 'Payment Declined', 'Paid']).order_by(Lower("name")), empty_label=None) + status = forms.ModelChoiceField( + queryset=AllocationStatusChoice.objects.filter( + name__in=["Payment Pending", "Payment Requested", "Payment Declined", "Paid"] + ).order_by(Lower("name")), + empty_label=None, + ) class AllocationAddUserForm(forms.Form): @@ -111,49 +184,43 @@ class AllocationAttributeDeleteForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() class AllocationSearchForm(forms.Form): - project = forms.CharField(label='Project Title', - max_length=100, required=False) - username = forms.CharField( - label='Username', max_length=100, required=False) + project = forms.CharField(label="Project Title", max_length=100, required=False) + username = forms.CharField(label="Username", max_length=100, required=False) resource_type = forms.ModelChoiceField( - label='Resource Type', - queryset=ResourceType.objects.all().order_by(Lower("name")), - required=False) + label="Resource Type", queryset=ResourceType.objects.all().order_by(Lower("name")), required=False + ) resource_name = forms.ModelMultipleChoiceField( - label='Resource Name', - queryset=Resource.objects.filter( - is_allocatable=True).order_by(Lower("name")), - required=False) + label="Resource Name", + queryset=Resource.objects.select_related("resource_type").filter(is_allocatable=True).order_by(Lower("name")), + required=False, + ) allocation_attribute_name = forms.ModelChoiceField( - label='Allocation Attribute Name', + label="Allocation Attribute Name", queryset=AllocationAttributeType.objects.all().order_by(Lower("name")), - required=False) - allocation_attribute_value = forms.CharField( - label='Allocation Attribute Value', max_length=100, required=False) - end_date = forms.DateField( - label='End Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) + required=False, + ) + allocation_attribute_value = forms.CharField(label="Allocation Attribute Value", max_length=100, required=False) + end_date = forms.DateField(label="End Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False) active_from_now_until_date = forms.DateField( - label='Active from Now Until Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) + label="Active from Now Until Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) status = forms.ModelMultipleChoiceField( widget=forms.CheckboxSelectMultiple, queryset=AllocationStatusChoice.objects.all().order_by(Lower("name")), - required=False) + required=False, + ) show_all_allocations = forms.BooleanField(initial=False, required=False) class AllocationReviewUserForm(forms.Form): ALLOCATION_REVIEW_USER_CHOICES = ( - ('keep_in_allocation_and_project', 'Keep in allocation and project'), - ('keep_in_project_only', 'Remove from this allocation only'), - ('remove_from_project', 'Remove from project'), + ("keep_in_allocation_and_project", "Keep in allocation and project"), + ("keep_in_project_only", "Remove from this allocation only"), + ("remove_from_project", "Remove from project"), ) username = forms.CharField(max_length=150, disabled=True) @@ -166,20 +233,20 @@ class AllocationReviewUserForm(forms.Form): class AllocationInvoiceNoteDeleteForm(forms.Form): pk = forms.IntegerField(required=False, disabled=True) note = forms.CharField(widget=forms.Textarea, disabled=True) - author = forms.CharField( - max_length=512, required=False, disabled=True) + author = forms.CharField(max_length=512, required=False, disabled=True) selected = forms.BooleanField(initial=False, required=False) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() class AllocationAccountForm(forms.ModelForm): - class Meta: model = AllocationAccount - fields = ['name', ] + fields = [ + "name", + ] class AllocationAttributeChangeForm(forms.Form): @@ -190,14 +257,14 @@ class AllocationAttributeChangeForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() def clean(self): cleaned_data = super().clean() - if cleaned_data.get('new_value') != "": - allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get('pk')) - allocation_attribute.value = cleaned_data.get('new_value') + if cleaned_data.get("new_value") != "": + allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get("pk")) + allocation_attribute.value = cleaned_data.get("new_value") allocation_attribute.clean() @@ -210,52 +277,75 @@ class AllocationAttributeUpdateForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['change_pk'].widget = forms.HiddenInput() - self.fields['attribute_pk'].widget = forms.HiddenInput() + self.fields["change_pk"].widget = forms.HiddenInput() + self.fields["attribute_pk"].widget = forms.HiddenInput() def clean(self): cleaned_data = super().clean() - allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get('attribute_pk')) + allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get("attribute_pk")) - allocation_attribute.value = cleaned_data.get('new_value') + allocation_attribute.value = cleaned_data.get("new_value") + allocation_attribute.clean() + + +class AllocationAttributeEditForm(forms.Form): + attribute_pk = forms.IntegerField(required=False, disabled=True) + name = forms.CharField(max_length=150, required=False, disabled=True) + orig_value = forms.CharField(max_length=150, required=False, disabled=True) + value = forms.CharField(max_length=150, required=False, disabled=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["attribute_pk"].widget = forms.HiddenInput() + + def clean(self): + cleaned_data = super().clean() + allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get("attribute_pk")) + + allocation_attribute.value = cleaned_data.get("value") allocation_attribute.clean() class AllocationChangeForm(forms.Form): - EXTENSION_CHOICES = [ - (0, "No Extension") - ] + EXTENSION_CHOICES = [(0, "No Extension")] for choice in ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS: EXTENSION_CHOICES.append((choice, "{} days".format(choice))) end_date_extension = forms.TypedChoiceField( - label='Request End Date Extension', - choices = EXTENSION_CHOICES, + label="Request End Date Extension", + choices=EXTENSION_CHOICES, coerce=int, required=False, - empty_value=0,) + empty_value=0, + ) justification = forms.CharField( - label='Justification for Changes', + label="Justification for Changes", widget=forms.Textarea, required=True, - help_text='Justification for requesting this allocation change request.') + help_text="Justification for requesting this allocation change request.", + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) class AllocationChangeNoteForm(forms.Form): - notes = forms.CharField( - max_length=512, - label='Notes', - required=False, - widget=forms.Textarea, - help_text="Leave any feedback about the allocation change request.") + notes = forms.CharField( + max_length=512, + label="Notes", + required=False, + widget=forms.Textarea, + help_text="Leave any feedback about the allocation change request.", + ) + class AllocationAttributeCreateForm(forms.ModelForm): class Meta: model = AllocationAttribute - fields = '__all__' + fields = "__all__" + def __init__(self, *args, **kwargs): - super(AllocationAttributeCreateForm, self).__init__(*args, **kwargs) - self.fields['allocation_attribute_type'].queryset = self.fields['allocation_attribute_type'].queryset.order_by(Lower('name')) + super(AllocationAttributeCreateForm, self).__init__(*args, **kwargs) + self.fields["allocation_attribute_type"].queryset = self.fields["allocation_attribute_type"].queryset.order_by( + Lower("name") + ) diff --git a/coldfront/core/allocation/management/__init__.py b/coldfront/core/allocation/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/allocation/management/__init__.py +++ b/coldfront/core/allocation/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/allocation/management/commands/__init__.py b/coldfront/core/allocation/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/allocation/management/commands/__init__.py +++ b/coldfront/core/allocation/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/allocation/management/commands/add_allocation_defaults.py b/coldfront/core/allocation/management/commands/add_allocation_defaults.py index ca5c4fc370..ebf4be06d6 100644 --- a/coldfront/core/allocation/management/commands/add_allocation_defaults.py +++ b/coldfront/core/allocation/management/commands/add_allocation_defaults.py @@ -1,57 +1,82 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.management.base import BaseCommand -from coldfront.core.allocation.models import (AttributeType, - AllocationAttributeType, - AllocationStatusChoice, - AllocationChangeStatusChoice, - AllocationUserStatusChoice) +from coldfront.core.allocation.models import ( + AllocationAttributeType, + AllocationChangeStatusChoice, + AllocationStatusChoice, + AllocationUserStatusChoice, + AttributeType, +) class Command(BaseCommand): - help = 'Add default allocation related choices' + help = "Add default allocation related choices" def handle(self, *args, **options): - - for attribute_type in ('Date', 'Float', 'Int', 'Text', 'Yes/No', 'No', - 'Attribute Expanded Text'): + for attribute_type in ("Date", "Float", "Int", "Text", "Yes/No", "No", "Attribute Expanded Text"): AttributeType.objects.get_or_create(name=attribute_type) - for choice in ('Active', 'Denied', 'Expired', - 'New', 'Paid', 'Payment Pending', - 'Payment Requested', 'Payment Declined', - 'Renewal Requested', 'Revoked', 'Unpaid',): + for choice in ( + "Active", + "Approved", + "Denied", + "Expired", + "New", + "Paid", + "Payment Pending", + "Payment Requested", + "Payment Declined", + "Pending", + "Renewal Requested", + "Revoked", + "Unpaid", + ): AllocationStatusChoice.objects.get_or_create(name=choice) - for choice in ('Pending', 'Approved', 'Denied',): + for choice in ( + "Pending", + "Approved", + "Denied", + ): AllocationChangeStatusChoice.objects.get_or_create(name=choice) - for choice in ('Active', 'Error', 'Removed', ): + for choice in ("Active", "Error", "Removed", "PendingEULA", "DeclinedEULA"): AllocationUserStatusChoice.objects.get_or_create(name=choice) - for name, attribute_type, has_usage, is_private in ( - ('Cloud Account Name', 'Text', False, False), - ('CLOUD_USAGE_NOTIFICATION', 'Yes/No', False, True), - ('Core Usage (Hours)', 'Int', True, False), - ('Accelerator Usage (Hours)', 'Int', True, False), - ('Cloud Storage Quota (TB)', 'Float', True, False), - ('EXPIRE NOTIFICATION', 'Yes/No', False, True), - ('freeipa_group', 'Text', False, False), - ('Is Course?', 'Yes/No', False, True), - ('Paid', 'Float', False, False), - ('Paid Cloud Support (Hours)', 'Float', True, True), - ('Paid Network Support (Hours)', 'Float', True, True), - ('Paid Storage Support (Hours)', 'Float', True, True), - ('Purchase Order Number', 'Int', False, True), - ('send_expiry_email_on_date', 'Date', False, True), - ('slurm_account_name', 'Text', False, False), - ('slurm_specs', 'Attribute Expanded Text', False, True), - ('slurm_specs_attriblist', 'Text', False, True), - ('slurm_user_specs', 'Attribute Expanded Text', False, True), - ('slurm_user_specs_attriblist', 'Text', False, True), - ('Storage Quota (GB)', 'Int', False, False), - ('Storage_Group_Name', 'Text', False, False), - ('SupportersQOS', 'Yes/No', False, False), - ('SupportersQOSExpireDate', 'Date', False, False), + for name, attribute_type, has_usage, is_private, is_required, is_changeable in ( + ("Cloud Account Name", "Text", False, False, False, False), + ("CLOUD_USAGE_NOTIFICATION", "Yes/No", False, True, False, False), + ("Core Usage (Hours)", "Int", True, False, False, False), + ("Accelerator Usage (Hours)", "Int", True, False, False, False), + ("Cloud Storage Quota (TB)", "Float", True, False, False, False), + ("EXPIRE NOTIFICATION", "Yes/No", False, True, False, False), + ("freeipa_group", "Text", False, False, False, False), + ("Is Course?", "Yes/No", False, True, False, False), + ("Paid", "Float", False, False, False, False), + ("Paid Cloud Support (Hours)", "Float", True, True, False, False), + ("Paid Network Support (Hours)", "Float", True, True, False, False), + ("Paid Storage Support (Hours)", "Float", True, True, False, False), + ("Purchase Order Number", "Int", False, True, False, False), + ("send_expiry_email_on_date", "Date", False, True, False, False), + ("slurm_account_name", "Text", False, False, False, False), + ("slurm_specs", "Attribute Expanded Text", False, True, False, False), + ("slurm_specs_attriblist", "Text", False, True, False, False), + ("slurm_user_specs", "Attribute Expanded Text", False, True, False, False), + ("slurm_user_specs_attriblist", "Text", False, True, False, False), + ("Storage Quota (GB)", "Int", False, False, False, False), + ("Storage_Group_Name", "Text", False, False, False, False), + ("SupportersQOS", "Yes/No", False, False, False, False), + ("SupportersQOSExpireDate", "Date", False, False, False, False), ): - AllocationAttributeType.objects.get_or_create(name=name, attribute_type=AttributeType.objects.get( - name=attribute_type), has_usage=has_usage, is_private=is_private) + AllocationAttributeType.objects.get_or_create( + name=name, + attribute_type=AttributeType.objects.get(name=attribute_type), + has_usage=has_usage, + is_private=is_private, + is_required=is_required, + is_changeable=is_changeable, + ) diff --git a/coldfront/core/allocation/management/commands/enable_change_requests_globally.py b/coldfront/core/allocation/management/commands/enable_change_requests_globally.py index 15581770a8..912f052524 100644 --- a/coldfront/core/allocation/management/commands/enable_change_requests_globally.py +++ b/coldfront/core/allocation/management/commands/enable_change_requests_globally.py @@ -1,11 +1,16 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.management.base import BaseCommand from coldfront.core.allocation.models import Allocation + class Command(BaseCommand): - help = 'Enable change requests on all allocations' + help = "Enable change requests on all allocations" def handle(self, *args, **options): - answer = input(f'Enable change requests on all {Allocation.objects.count()} allocations? [y/N]: ') - if answer == 'Y' or answer == 'y': + answer = input(f"Enable change requests on all {Allocation.objects.count()} allocations? [y/N]: ") + if answer == "Y" or answer == "y": Allocation.objects.all().update(is_changeable=True) diff --git a/coldfront/core/allocation/migrations/0001_initial.py b/coldfront/core/allocation/migrations/0001_initial.py index 22ab23d556..3854f8347d 100644 --- a/coldfront/core/allocation/migrations/0001_initial.py +++ b/coldfront/core/allocation/migrations/0001_initial.py @@ -1,297 +1,640 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('project', '0001_initial'), + ("project", "0001_initial"), ] operations = [ migrations.CreateModel( - name='Allocation', + name="Allocation", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('quantity', models.IntegerField(default=1)), - ('start_date', models.DateField(blank=True, null=True)), - ('end_date', models.DateField(blank=True, null=True)), - ('justification', models.TextField()), - ('description', models.CharField(blank=True, max_length=512, null=True)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("quantity", models.IntegerField(default=1)), + ("start_date", models.DateField(blank=True, null=True)), + ("end_date", models.DateField(blank=True, null=True)), + ("justification", models.TextField()), + ("description", models.CharField(blank=True, max_length=512, null=True)), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), ], options={ - 'ordering': ['end_date'], - 'permissions': (('can_view_all_allocations', 'Can view all allocations'), ('can_review_allocation_requests', 'Can review allocation requests'), ('can_manage_invoice', 'Can manage invoice')), + "ordering": ["end_date"], + "permissions": ( + ("can_view_all_allocations", "Can view all allocations"), + ("can_review_allocation_requests", "Can review allocation requests"), + ("can_manage_invoice", "Can manage invoice"), + ), }, ), migrations.CreateModel( - name='AllocationAttribute', + name="AllocationAttribute", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=128)), - ('allocation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=128)), + ( + "allocation", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.Allocation"), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='AllocationAttributeType', + name="AllocationAttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=50)), - ('has_usage', models.BooleanField(default=False)), - ('is_required', models.BooleanField(default=False)), - ('is_unique', models.BooleanField(default=False)), - ('is_private', models.BooleanField(default=True)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=50)), + ("has_usage", models.BooleanField(default=False)), + ("is_required", models.BooleanField(default=False)), + ("is_unique", models.BooleanField(default=False)), + ("is_private", models.BooleanField(default=True)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='AllocationStatusChoice', + name="AllocationStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='AllocationUserStatusChoice', + name="AllocationUserStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='AttributeType', + name="AttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='AllocationAttributeUsage', + name="AllocationAttributeUsage", fields=[ - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('allocation_attribute', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='allocation.AllocationAttribute')), - ('value', models.FloatField(default=0)), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "allocation_attribute", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="allocation.AllocationAttribute", + ), + ), + ("value", models.FloatField(default=0)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='HistoricalAllocationUser', + name="HistoricalAllocationUser", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('allocation', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.Allocation')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationUserStatusChoice', verbose_name='Allocation User Status')), - ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "allocation", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.Allocation", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationUserStatusChoice", + verbose_name="Allocation User Status", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical allocation user', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation user", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalAllocationAttributeUsage', + name="HistoricalAllocationAttributeUsage", fields=[ - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.FloatField(default=0)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('allocation_attribute', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationAttribute')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.FloatField(default=0)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "allocation_attribute", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationAttribute", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical allocation attribute usage', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation attribute usage", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalAllocationAttributeType', + name="HistoricalAllocationAttributeType", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=50)), - ('has_usage', models.BooleanField(default=False)), - ('is_required', models.BooleanField(default=False)), - ('is_unique', models.BooleanField(default=False)), - ('is_private', models.BooleanField(default=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('attribute_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AttributeType')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=50)), + ("has_usage", models.BooleanField(default=False)), + ("is_required", models.BooleanField(default=False)), + ("is_unique", models.BooleanField(default=False)), + ("is_private", models.BooleanField(default=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "attribute_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AttributeType", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical allocation attribute type', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation attribute type", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalAllocationAttribute', + name="HistoricalAllocationAttribute", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=128)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('allocation', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.Allocation')), - ('allocation_attribute_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationAttributeType')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=128)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "allocation", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.Allocation", + ), + ), + ( + "allocation_attribute_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationAttributeType", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical allocation attribute', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation attribute", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalAllocation', + name="HistoricalAllocation", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('quantity', models.IntegerField(default=1)), - ('start_date', models.DateField(blank=True, null=True)), - ('end_date', models.DateField(blank=True, null=True)), - ('justification', models.TextField()), - ('description', models.CharField(blank=True, max_length=512, null=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationStatusChoice', verbose_name='Status')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("quantity", models.IntegerField(default=1)), + ("start_date", models.DateField(blank=True, null=True)), + ("end_date", models.DateField(blank=True, null=True)), + ("justification", models.TextField()), + ("description", models.CharField(blank=True, max_length=512, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationStatusChoice", + verbose_name="Status", + ), + ), ], options={ - 'verbose_name': 'historical allocation', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='AllocationUserNote', + name="AllocationUserNote", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('is_private', models.BooleanField(default=True)), - ('note', models.TextField()), - ('allocation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation')), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("is_private", models.BooleanField(default=True)), + ("note", models.TextField()), + ( + "allocation", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.Allocation"), + ), + ("author", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='AllocationUser', + name="AllocationUser", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('allocation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation')), - ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationUserStatusChoice', verbose_name='Allocation User Status')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "allocation", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.Allocation"), + ), + ( + "status", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="allocation.AllocationUserStatusChoice", + verbose_name="Allocation User Status", + ), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'verbose_name_plural': 'Allocation User Status', + "verbose_name_plural": "Allocation User Status", }, ), migrations.AddField( - model_name='allocationattributetype', - name='attribute_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AttributeType'), + model_name="allocationattributetype", + name="attribute_type", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.AttributeType"), ), migrations.AddField( - model_name='allocationattribute', - name='allocation_attribute_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationAttributeType'), + model_name="allocationattribute", + name="allocation_attribute_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="allocation.AllocationAttributeType" + ), ), migrations.CreateModel( - name='AllocationAdminNote', + name="AllocationAdminNote", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('note', models.TextField()), - ('allocation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation')), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("note", models.TextField()), + ( + "allocation", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.Allocation"), + ), + ("author", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='AllocationAccount', + name="AllocationAccount", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64, unique=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64, unique=True)), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), ] diff --git a/coldfront/core/allocation/migrations/0002_auto_20190718_1451.py b/coldfront/core/allocation/migrations/0002_auto_20190718_1451.py index db781eb739..89a26c0a11 100644 --- a/coldfront/core/allocation/migrations/0002_auto_20190718_1451.py +++ b/coldfront/core/allocation/migrations/0002_auto_20190718_1451.py @@ -1,31 +1,38 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('resource', '0001_initial'), - ('allocation', '0001_initial'), + ("resource", "0001_initial"), + ("allocation", "0001_initial"), ] operations = [ migrations.AddField( - model_name='allocation', - name='resources', - field=models.ManyToManyField(to='resource.Resource'), + model_name="allocation", + name="resources", + field=models.ManyToManyField(to="resource.Resource"), ), migrations.AddField( - model_name='allocation', - name='status', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationStatusChoice', verbose_name='Status'), + model_name="allocation", + name="status", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="allocation.AllocationStatusChoice", + verbose_name="Status", + ), ), migrations.AlterUniqueTogether( - name='allocationuser', - unique_together={('user', 'allocation')}, + name="allocationuser", + unique_together={("user", "allocation")}, ), ] diff --git a/coldfront/core/allocation/migrations/0003_auto_20191018_1049.py b/coldfront/core/allocation/migrations/0003_auto_20191018_1049.py index 7581bd0a8b..f11fa63fcc 100644 --- a/coldfront/core/allocation/migrations/0003_auto_20191018_1049.py +++ b/coldfront/core/allocation/migrations/0003_auto_20191018_1049.py @@ -1,23 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2019-10-18 14:49 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('allocation', '0002_auto_20190718_1451'), + ("allocation", "0002_auto_20190718_1451"), ] operations = [ migrations.AddField( - model_name='allocation', - name='is_locked', + model_name="allocation", + name="is_locked", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='historicalallocation', - name='is_locked', + model_name="historicalallocation", + name="is_locked", field=models.BooleanField(default=False), ), ] diff --git a/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py b/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py index 8a5b95d0a0..3acb9922e3 100644 --- a/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py +++ b/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py @@ -1,135 +1,263 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.18 on 2021-11-02 14:17 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('allocation', '0003_auto_20191018_1049'), + ("allocation", "0003_auto_20191018_1049"), ] operations = [ migrations.CreateModel( - name='AllocationChangeRequest', + name="AllocationChangeRequest", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('end_date_extension', models.IntegerField(blank=True, null=True)), - ('justification', models.TextField()), - ('notes', models.CharField(blank=True, max_length=512, null=True)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("end_date_extension", models.IntegerField(blank=True, null=True)), + ("justification", models.TextField()), + ("notes", models.CharField(blank=True, max_length=512, null=True)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='AllocationChangeStatusChoice', + name="AllocationChangeStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.AddField( - model_name='allocation', - name='is_changeable', + model_name="allocation", + name="is_changeable", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='allocationattributetype', - name='is_changeable', + model_name="allocationattributetype", + name="is_changeable", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='historicalallocation', - name='is_changeable', + model_name="historicalallocation", + name="is_changeable", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='historicalallocationattributetype', - name='is_changeable', + model_name="historicalallocationattributetype", + name="is_changeable", field=models.BooleanField(default=False), ), migrations.CreateModel( - name='HistoricalAllocationChangeRequest', + name="HistoricalAllocationChangeRequest", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('end_date_extension', models.IntegerField(blank=True, null=True)), - ('justification', models.TextField()), - ('notes', models.CharField(blank=True, max_length=512, null=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('allocation', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.Allocation')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationChangeStatusChoice', verbose_name='Status')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("end_date_extension", models.IntegerField(blank=True, null=True)), + ("justification", models.TextField()), + ("notes", models.CharField(blank=True, max_length=512, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "allocation", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.Allocation", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationChangeStatusChoice", + verbose_name="Status", + ), + ), ], options={ - 'verbose_name': 'historical allocation change request', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation change request", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalAllocationAttributeChangeRequest', + name="HistoricalAllocationAttributeChangeRequest", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('new_value', models.CharField(max_length=128)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('allocation_attribute', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationAttribute')), - ('allocation_change_request', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationChangeRequest')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("new_value", models.CharField(max_length=128)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "allocation_attribute", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationAttribute", + ), + ), + ( + "allocation_change_request", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationChangeRequest", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical allocation attribute change request', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation attribute change request", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.AddField( - model_name='allocationchangerequest', - name='allocation', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation'), + model_name="allocationchangerequest", + name="allocation", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.Allocation"), ), migrations.AddField( - model_name='allocationchangerequest', - name='status', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationChangeStatusChoice', verbose_name='Status'), + model_name="allocationchangerequest", + name="status", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="allocation.AllocationChangeStatusChoice", + verbose_name="Status", + ), ), migrations.CreateModel( - name='AllocationAttributeChangeRequest', + name="AllocationAttributeChangeRequest", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('new_value', models.CharField(max_length=128)), - ('allocation_attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationAttribute')), - ('allocation_change_request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationChangeRequest')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("new_value", models.CharField(max_length=128)), + ( + "allocation_attribute", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.AllocationAttribute"), + ), + ( + "allocation_change_request", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="allocation.AllocationChangeRequest" + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py b/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py index 7512c62f29..d0b18e2d05 100644 --- a/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py +++ b/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py @@ -1,18 +1,25 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.24 on 2021-11-17 19:13 from django.db import migrations def create_status_choices(apps, schema_editor): - AllocationChangeStatusChoice = apps.get_model('allocation', "AllocationChangeStatusChoice") - for choice in ('Pending', 'Approved', 'Denied',): + AllocationChangeStatusChoice = apps.get_model("allocation", "AllocationChangeStatusChoice") + for choice in ( + "Pending", + "Approved", + "Denied", + ): AllocationChangeStatusChoice.objects.get_or_create(name=choice) class Migration(migrations.Migration): - dependencies = [ - ('allocation', '0004_auto_20211102_1017'), + ("allocation", "0004_auto_20211102_1017"), ] operations = [ diff --git a/coldfront/core/allocation/migrations/0006_alter_historicalallocation_options_and_more.py b/coldfront/core/allocation/migrations/0006_alter_historicalallocation_options_and_more.py new file mode 100644 index 0000000000..75f1489cbf --- /dev/null +++ b/coldfront/core/allocation/migrations/0006_alter_historicalallocation_options_and_more.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Generated by Django 4.2.23 on 2025-10-17 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("allocation", "0005_auto_20211117_1413"), + ] + + operations = [ + migrations.AlterModelOptions( + name="historicalallocation", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical allocation", + "verbose_name_plural": "historical allocations", + }, + ), + migrations.AlterModelOptions( + name="historicalallocationattribute", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical allocation attribute", + "verbose_name_plural": "historical allocation attributes", + }, + ), + migrations.AlterModelOptions( + name="historicalallocationattributechangerequest", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical allocation attribute change request", + "verbose_name_plural": "historical allocation attribute change requests", + }, + ), + migrations.AlterModelOptions( + name="historicalallocationattributetype", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical allocation attribute type", + "verbose_name_plural": "historical allocation attribute types", + }, + ), + migrations.AlterModelOptions( + name="historicalallocationattributeusage", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical allocation attribute usage", + "verbose_name_plural": "historical allocation attribute usages", + }, + ), + migrations.AlterModelOptions( + name="historicalallocationchangerequest", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical allocation change request", + "verbose_name_plural": "historical allocation change requests", + }, + ), + migrations.AlterModelOptions( + name="historicalallocationuser", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical allocation user", + "verbose_name_plural": "historical Allocation User Status", + }, + ), + migrations.AlterField( + model_name="historicalallocation", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalallocationattribute", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalallocationattributechangerequest", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalallocationattributetype", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalallocationattributeusage", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalallocationchangerequest", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalallocationuser", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + ] diff --git a/coldfront/core/allocation/migrations/__init__.py b/coldfront/core/allocation/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/allocation/migrations/__init__.py +++ b/coldfront/core/allocation/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index 50d8fa591d..fafa997aa5 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -1,47 +1,58 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime -import importlib import logging -from ast import literal_eval from enum import Enum -from django.conf import settings +from django.contrib.auth import get_user_model from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import models -from django.utils.html import mark_safe +from django.urls import reverse +from django.utils.html import escape, format_html from django.utils.module_loading import import_string +from django.utils.safestring import SafeString from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords +import coldfront.core.attribute_expansion as attribute_expansion +from coldfront.config.core import ALLOCATION_EULA_ENABLE +from coldfront.core.allocation.signals import allocation_activate_user, allocation_remove_user from coldfront.core.project.models import Project, ProjectPermission from coldfront.core.resource.models import Resource from coldfront.core.utils.common import import_from_settings -import coldfront.core.attribute_expansion as attribute_expansion +from coldfront.core.utils.mail import build_link, send_email_template +from coldfront.core.utils.validate import AttributeValidator logger = logging.getLogger(__name__) -ALLOCATION_ATTRIBUTE_VIEW_LIST = import_from_settings( - 'ALLOCATION_ATTRIBUTE_VIEW_LIST', []) -ALLOCATION_FUNCS_ON_EXPIRE = import_from_settings( - 'ALLOCATION_FUNCS_ON_EXPIRE', []) -ALLOCATION_RESOURCE_ORDERING = import_from_settings( - 'ALLOCATION_RESOURCE_ORDERING', - ['-is_allocatable', 'name']) +ALLOCATION_ATTRIBUTE_VIEW_LIST = import_from_settings("ALLOCATION_ATTRIBUTE_VIEW_LIST", []) +ALLOCATION_FUNCS_ON_EXPIRE = import_from_settings("ALLOCATION_FUNCS_ON_EXPIRE", []) +ALLOCATION_RESOURCE_ORDERING = import_from_settings("ALLOCATION_RESOURCE_ORDERING", ["-is_allocatable", "name"]) + +EMAIL_SENDER = import_from_settings("EMAIL_SENDER") + class AllocationPermission(Enum): - """ A project permission stores the user and manager fields of a project. """ + """An allocation permission stores the user and manager fields of a project.""" + + USER = "USER" + MANAGER = "MANAGER" - USER = 'USER' - MANAGER = 'MANAGER' class AllocationStatusChoice(TimeStampedModel): - """ A project status choice indicates the status of the project. Examples include Active, Archived, and New. - + """A project status choice indicates the status of the project. Examples include Active, Archived, and New. + Attributes: name (str): name of project status choice """ + class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class AllocationStatusChoiceManager(models.Manager): def get_by_natural_key(self, name): @@ -56,9 +67,10 @@ def __str__(self): def natural_key(self): return (self.name,) + class Allocation(TimeStampedModel): - """ An allocation provides users access to a resource. - + """An allocation provides users access to a resource. + Attributes: project (Project): links the project the allocation falls under resources (Resource): links resources that this allocation allocates @@ -73,18 +85,22 @@ class Allocation(TimeStampedModel): """ class Meta: - ordering = ['end_date', ] + ordering = [ + "end_date", + ] permissions = ( - ('can_view_all_allocations', 'Can view all allocations'), - ('can_review_allocation_requests', - 'Can review allocation requests'), - ('can_manage_invoice', 'Can manage invoice'), + ("can_view_all_allocations", "Can view all allocations"), + ("can_review_allocation_requests", "Can review allocation requests"), + ("can_manage_invoice", "Can manage invoice"), ) - project = models.ForeignKey(Project, on_delete=models.CASCADE,) + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + ) resources = models.ManyToManyField(Resource) - status = models.ForeignKey(AllocationStatusChoice, on_delete=models.CASCADE, verbose_name='Status') + status = models.ForeignKey(AllocationStatusChoice, on_delete=models.CASCADE, verbose_name="Status") quantity = models.IntegerField(default=1) start_date = models.DateField(blank=True, null=True) end_date = models.DateField(blank=True, null=True) @@ -95,37 +111,34 @@ class Meta: history = HistoricalRecords() def clean(self): - """ Validates the allocation and raises errors if the allocation is invalid. """ + """Validates the allocation and raises errors if the allocation is invalid.""" - if self.status.name == 'Expired': + if self.status.name == "Expired": if not self.end_date: - raise ValidationError('You have to set the end date.') + raise ValidationError("You have to set the end date.") if self.end_date > datetime.datetime.now().date(): - raise ValidationError( - 'End date cannot be greater than today.') + raise ValidationError("End date cannot be greater than today.") if self.start_date > self.end_date: - raise ValidationError( - 'End date cannot be greater than start date.') + raise ValidationError("End date cannot be greater than start date.") - elif self.status.name == 'Active': + elif self.status.name == "Active": if not self.start_date: - raise ValidationError('You have to set the start date.') + raise ValidationError("You have to set the start date.") if not self.end_date: - raise ValidationError('You have to set the end date.') + raise ValidationError("You have to set the end date.") if self.start_date > self.end_date: - raise ValidationError( - 'Start date cannot be greater than the end date.') + raise ValidationError("Start date cannot be greater than the end date.") def save(self, *args, **kwargs): - """ Saves the project. """ + """Saves the project.""" if self.pk: old_obj = Allocation.objects.get(pk=self.pk) - if old_obj.status.name != self.status.name and self.status.name == 'Expired': + if old_obj.status.name != self.status.name and self.status.name == "Expired": for func_string in ALLOCATION_FUNCS_ON_EXPIRE: func_to_run = import_string(func_string) func_to_run(self.pk) @@ -134,7 +147,7 @@ def save(self, *args, **kwargs): @property def expires_in(self): - """ + """ Returns: int: the number of days until the allocation expires """ @@ -142,40 +155,47 @@ def expires_in(self): return (self.end_date - datetime.date.today()).days @property - def get_information(self): - """ + def get_information(self) -> SafeString: + """ Returns: - str: the allocation's attribute type, usage out of total value, and usage out of total value as a percentage + SafeString: the allocation's attribute type, usage out of total value, and usage out of total value as a percentage """ - html_string = '' - for attribute in self.allocationattribute_set.all(): + html_string = escape("") + for attribute in self.allocationattribute_set.select_related( + "allocation_attribute_type", "allocationattributeusage" + ).all(): if attribute.allocation_attribute_type.name in ALLOCATION_ATTRIBUTE_VIEW_LIST: - html_string += '%s: %s
' % ( - attribute.allocation_attribute_type.name, attribute.value) + html_substring = format_html("{}: {}
", attribute.allocation_attribute_type.name, attribute.value) + html_string += html_substring - if hasattr(attribute, 'allocationattributeusage'): + if hasattr(attribute, "allocationattributeusage"): try: - percent = round(float(attribute.allocationattributeusage.value) / - float(attribute.value) * 10000) / 100 + percent = ( + round(float(attribute.allocationattributeusage.value) / float(attribute.value) * 10000) / 100 + ) except ValueError: - percent = 'Invalid Value' - logger.error("Allocation attribute '%s' is not an int but has a usage", - attribute.allocation_attribute_type.name) + percent = "Invalid Value" + logger.error( + "Allocation attribute '%s' is not an int but has a usage", + attribute.allocation_attribute_type.name, + ) except ZeroDivisionError: percent = 100 - logger.error("Allocation attribute '%s' == 0 but has a usage", - attribute.allocation_attribute_type.name) + logger.error( + "Allocation attribute '%s' == 0 but has a usage", attribute.allocation_attribute_type.name + ) - string = '{}: {}/{} ({} %)
'.format( + html_substring = format_html( + "{}: {}/{} ({} %)
", attribute.allocation_attribute_type.name, attribute.allocationattributeusage.value, attribute.value, - percent + percent, ) - html_string += string + html_string += html_substring - return mark_safe(html_string) + return html_string @property def get_resources_as_string(self): @@ -184,8 +204,7 @@ def get_resources_as_string(self): str: the resources for the allocation """ - return ', '.join([ele.name for ele in self.resources.all().order_by( - *ALLOCATION_RESOURCE_ORDERING)]) + return ", ".join([ele.name for ele in self.resources.all().order_by(*ALLOCATION_RESOURCE_ORDERING)]) @property def get_resources_as_list(self): @@ -194,7 +213,7 @@ def get_resources_as_list(self): list[Resource]: the resources for the allocation """ - return [ele for ele in self.resources.all().order_by('-is_allocatable')] + return [ele for ele in self.resources.all().order_by("-is_allocatable")] @property def get_parent_resource(self): @@ -202,19 +221,17 @@ def get_parent_resource(self): Returns: Resource: the parent resource for the allocation """ - - if self.resources.count() == 1: - return self.resources.first() + resources = self.resources.select_related("resource_type") + if len(resources) == 1: + return resources.first() else: - parent = self.resources.order_by( - *ALLOCATION_RESOURCE_ORDERING).first() + parent = resources.order_by(*ALLOCATION_RESOURCE_ORDERING).first() if parent: return parent # Fallback - return self.resources.first() + return resources.first() - def get_attribute(self, name, expand=True, typed=True, - extra_allocations=[]): + def get_attribute(self, name, expand=True, typed=True, extra_allocations=[]): """ Params: name (str): name of the allocation attribute type @@ -226,12 +243,10 @@ def get_attribute(self, name, expand=True, typed=True, str: the value of the first attribute found for this allocation with the specified name """ - attr = self.allocationattribute_set.filter( - allocation_attribute_type__name=name).first() + attr = self.allocationattribute_set.filter(allocation_attribute_type__name=name).first() if attr: if expand: - return attr.expanded_value( - extra_allocations=extra_allocations, typed=typed) + return attr.expanded_value(extra_allocations=extra_allocations, typed=typed) else: if typed: return attr.typed_value() @@ -246,8 +261,7 @@ def set_usage(self, name, value): value (float): value to set usage to """ - attr = self.allocationattribute_set.filter( - allocation_attribute_type__name=name).first() + attr = self.allocationattribute_set.filter(allocation_attribute_type__name=name).first() if not attr: return @@ -255,16 +269,14 @@ def set_usage(self, name, value): return if not AllocationAttributeUsage.objects.filter(allocation_attribute=attr).exists(): - usage = AllocationAttributeUsage.objects.create( - allocation_attribute=attr) + usage = AllocationAttributeUsage.objects.create(allocation_attribute=attr) else: usage = attr.allocationattributeusage usage.value = value usage.save() - def get_attribute_list(self, name, expand=True, typed=True, - extra_allocations=[]): + def get_attribute_list(self, name, expand=True, typed=True, extra_allocations=[]): """ Params: name (str): name of the allocation @@ -276,11 +288,9 @@ def get_attribute_list(self, name, expand=True, typed=True, list: the list of values of the attributes found with specified name """ - attr = self.allocationattribute_set.filter( - allocation_attribute_type__name=name).all() + attr = self.allocationattribute_set.filter(allocation_attribute_type__name=name).all() if expand: - return [a.expanded_value(typed=typed, - extra_allocations=extra_allocations) for a in attr] + return [a.expanded_value(typed=typed, extra_allocations=extra_allocations) for a in attr] else: if typed: return [a.typed_value() for a in attr] @@ -297,9 +307,11 @@ def get_attribute_set(self, user): """ if user.is_superuser: - return self.allocationattribute_set.all().order_by('allocation_attribute_type__name') + return self.allocationattribute_set.all().order_by("allocation_attribute_type__name") - return self.allocationattribute_set.filter(allocation_attribute_type__is_private=False).order_by('allocation_attribute_type__name') + return self.allocationattribute_set.filter(allocation_attribute_type__is_private=False).order_by( + "allocation_attribute_type__name" + ) def user_permissions(self, user): """ @@ -321,7 +333,7 @@ def user_permissions(self, user): if ProjectPermission.PI in project_perms or ProjectPermission.MANAGER in project_perms: return [AllocationPermission.USER, AllocationPermission.MANAGER] - if self.allocationuser_set.filter(user=user, status__name__in=['Active', 'New', ]).exists(): + if self.allocationuser_set.filter(user=user, status__name__in=["Active", "New", "PendingEULA"]).exists(): return [AllocationPermission.USER] return [] @@ -342,9 +354,114 @@ def has_perm(self, user, perm): def __str__(self): return "%s (%s)" % (self.get_parent_resource.name, self.project.pi) + def get_eula(self): + if self.get_resources_as_list: + for res in self.get_resources_as_list: + if res.get_attribute(name="eula"): + return res.get_attribute(name="eula") + else: + return None + + def add_user(self, user, signal_sender=None): + """ + Adds a user to the allocation. + + If EULAs are enabled and this allocation has an associated EULA, marks the user + as "PendingEULA" and sends the user an email asking them to agree to the EULA. + Otherwise, marks the user as "Active." Also sends the `allocation_activate_user` + signal if the allocation status is "Active." + + Params: + user (User): User to add. + signal_sender (str): Sender for the `allocation_activate_user` signal. + """ + user_status = "Active" + + is_pending_eula = ALLOCATION_EULA_ENABLE and self.get_eula() and not user.userprofile.is_pi + if is_pending_eula: + user_status = "PendingEULA" + user_status_obj = AllocationUserStatusChoice.objects.get(name=user_status) + + allocation_user, _created = self.allocationuser_set.update_or_create( + user=user, defaults={"status": user_status_obj} + ) + + if is_pending_eula: + send_email_template( + f"Agree to EULA for {self.get_parent_resource.__str__()}", + "email/allocation_agree_to_eula.txt", + { + "resource": self.get_parent_resource, + "url": build_link(reverse("allocation-review-eula", kwargs={"pk": self.pk})), + }, + EMAIL_SENDER, + [user.email], + ) + + if self.status.name == "Active" and allocation_user.status.name == "Active": + allocation_activate_user.send(sender=signal_sender, allocation_user_pk=allocation_user.pk) + + def remove_user(self, user, signal_sender=None, ignore_user_not_found=True): + """ + Marks an `AllocationUser` as 'Removed' and sends the `allocation_remove_user` signal. + + Params: + user (User|AllocationUser): User to remove. + signal_sender (str): Sender for the `allocation_remove_user` signal. + ignore_user_not_found (bool): If enabled, logs a warning that the allocation user for + the provded user couldn't be found and returns. Otherwise, raises `AllocationUser.DoesNotExist`. + """ + if isinstance(user, AllocationUser): + allocation_user = user + elif isinstance(user, get_user_model()): + try: + allocation_user = self.allocationuser_set.get(user=user) + except AllocationUser.DoesNotExist: + if ignore_user_not_found: + logger.warning( + f"Cannot remove user={str(user)} for allocation pk={self.pk} - AllocationUser not found." + ) + return + else: + raise + allocation_user.status = AllocationUserStatusChoice.objects.get(name="Removed") + allocation_user.save() + allocation_remove_user.send(sender=signal_sender, allocation_user_pk=allocation_user.pk) + + def get_absolute_url(self): + return reverse("allocation-detail", kwargs={"pk": self.pk}) + + def get_user_emails(self, status_name="Active", ignore_disabled_notifications=False) -> set[str]: + """Gets a set of user emails for notifications. + + Params: + status_name (str): The name of the AllocationUserStatus to filter on. Defaults to "Active". + ignore_disabled_notifications (bool): If True, include project users + that have enable_notifications off. + + Returns: + set: A set of user emails for notifications. + """ + allocation_users = self.allocationuser_set.filter(status__name=status_name) + if ignore_disabled_notifications: + user_emails = set(allocation_users.values_list("user__email", flat=True)) + return user_emails + + users = allocation_users.values_list("user", flat=True) + filter_options = { + "user__in": users, + "staus__name": "Active", + "enable_notifications": True, + } + + project_users = self.project.projectuser_set.filter(**filter_options) + user_emails = set(project_users.values_list("user__email", flat=True)) + return user_emails + + class AllocationAdminNote(TimeStampedModel): - """ An allocation admin note is a note that an admin makes on an allocation. - + """An allocation admin note is a note that an admin makes on an allocation. + Attributes: allocation (Allocation): links the allocation to the note author (User): represents the User class of the admin who authored the note @@ -358,9 +475,10 @@ class AllocationAdminNote(TimeStampedModel): def __str__(self): return self.note + class AllocationUserNote(TimeStampedModel): - """ An allocation user note is a note that an user makes on an allocation. - + """An allocation user note is a note that an user makes on an allocation. + Attributes: allocation (Allocation): links the allocation to the note author (User): represents the User class of the user who authored the note @@ -376,9 +494,10 @@ class AllocationUserNote(TimeStampedModel): def __str__(self): return self.note + class AttributeType(TimeStampedModel): - """ An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. - + """An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. + Attributes: name (str): name of attribute data type """ @@ -389,11 +508,14 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class AllocationAttributeType(TimeStampedModel): - """ An allocation attribute type indicates the type of the attribute. Examples include Cloud Account Name and Core Usage (Hours). - + """An allocation attribute type indicates the type of the attribute. Examples include Cloud Account Name and Core Usage (Hours). + Attributes: attribute_type (AttributeType): indicates the data type of the attribute name (str): name of allocation attribute type @@ -414,61 +536,65 @@ class AllocationAttributeType(TimeStampedModel): history = HistoricalRecords() def __str__(self): - return '%s' % (self.name) + return "%s" % (self.name) class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class AllocationAttribute(TimeStampedModel): - """ An allocation attribute class links an allocation attribute type and an allocation. - + """An allocation attribute class links an allocation attribute type and an allocation. + Attributes: allocation_attribute_type (AllocationAttributeType): attribute type to link allocation (Allocation): allocation to link value (str): value of the allocation attribute """ - allocation_attribute_type = models.ForeignKey( - AllocationAttributeType, on_delete=models.CASCADE) + allocation_attribute_type = models.ForeignKey(AllocationAttributeType, on_delete=models.CASCADE) allocation = models.ForeignKey(Allocation, on_delete=models.CASCADE) value = models.CharField(max_length=128) history = HistoricalRecords() def save(self, *args, **kwargs): - """ Saves the allocation attribute. """ + """Saves the allocation attribute.""" super().save(*args, **kwargs) - if self.allocation_attribute_type.has_usage and not AllocationAttributeUsage.objects.filter(allocation_attribute=self).exists(): - AllocationAttributeUsage.objects.create( - allocation_attribute=self) + if ( + self.allocation_attribute_type.has_usage + and not AllocationAttributeUsage.objects.filter(allocation_attribute=self).exists() + ): + AllocationAttributeUsage.objects.create(allocation_attribute=self) def clean(self): - """ Validates the allocation attribute and raises errors if the allocation attribute is invalid. """ - - if self.allocation_attribute_type.is_unique and self.allocation.allocationattribute_set.filter(allocation_attribute_type=self.allocation_attribute_type).exclude(id=self.pk).exists(): - raise ValidationError("'{}' attribute already exists for this allocation.".format( - self.allocation_attribute_type)) + """Validates the allocation attribute and raises errors if the allocation attribute is invalid.""" + + if ( + self.allocation_attribute_type.is_unique + and self.allocation.allocationattribute_set.filter(allocation_attribute_type=self.allocation_attribute_type) + .exclude(id=self.pk) + .exists() + ): + raise ValidationError( + "'{}' attribute already exists for this allocation.".format(self.allocation_attribute_type) + ) expected_value_type = self.allocation_attribute_type.attribute_type.name.strip() - if expected_value_type == "Int" and not isinstance(literal_eval(self.value), int): - raise ValidationError( - 'Invalid Value "%s" for "%s". Value must be an integer.' % (self.value, self.allocation_attribute_type.name)) - elif expected_value_type == "Float" and not (isinstance(literal_eval(self.value), float) or isinstance(literal_eval(self.value), int)): - raise ValidationError( - 'Invalid Value "%s" for "%s". Value must be a float.' % (self.value, self.allocation_attribute_type.name)) - elif expected_value_type == "Yes/No" and self.value not in ["Yes", "No"]: - raise ValidationError( - 'Invalid Value "%s" for "%s". Allowed inputs are "Yes" or "No".' % (self.value, self.allocation_attribute_type.name)) + validator = AttributeValidator(self.value) + if expected_value_type == "Int": + validator.validate_int() + elif expected_value_type == "Float": + validator.validate_float() + elif expected_value_type == "Yes/No": + validator.validate_yes_no() elif expected_value_type == "Date": - try: - datetime.datetime.strptime(self.value.strip(), "%Y-%m-%d") - except ValueError: - raise ValidationError( - 'Invalid Value "%s" for "%s". Date must be in format YYYY-MM-DD' % (self.value, self.allocation_attribute_type.name)) + validator.validate_date() def __str__(self): - return '%s' % (self.allocation_attribute_type.name) + return "%s" % (self.allocation_attribute_type.name) def typed_value(self): """ @@ -478,10 +604,8 @@ def typed_value(self): raw_value = self.value atype_name = self.allocation_attribute_type.attribute_type.name - return attribute_expansion.convert_type( - value=raw_value, type_name=atype_name) - - + return attribute_expansion.convert_type(value=raw_value, type_name=atype_name) + def expanded_value(self, extra_allocations=[], typed=True): """ Params: @@ -491,7 +615,7 @@ def expanded_value(self, extra_allocations=[], typed=True): Returns: int, float, str: the value of the attribute after attribute expansion - For attributes with attribute type of 'Attribute Expanded Text' we look for an attribute with same name suffixed with '_attriblist' (this should be ResourceAttribute of the Resource associated with the attribute). If the attriblist attribute is found, we use it to generate a dictionary to use to expand the attribute value, and the expanded value is returned. + For attributes with attribute type of 'Attribute Expanded Text' we look for an attribute with same name suffixed with '_attriblist' (this should be ResourceAttribute of the Resource associated with the attribute). If the attriblist attribute is found, we use it to generate a dictionary to use to expand the attribute value, and the expanded value is returned. If the expansion fails, or if no attriblist attribute is found, or if the attribute type is not 'Attribute Expanded Text', we just return the raw value. """ @@ -501,56 +625,59 @@ def expanded_value(self, extra_allocations=[], typed=True): # Try to convert to python type as per AttributeType raw_value = self.typed_value() - if not attribute_expansion.is_expandable_type( - self.allocation_attribute_type.attribute_type): + if not attribute_expansion.is_expandable_type(self.allocation_attribute_type.attribute_type): # We are not an expandable type, return raw_value return raw_value - allocs = [ self.allocation ] + extra_allocations + allocs = [self.allocation] + extra_allocations resources = list(self.allocation.resources.all()) attrib_name = self.allocation_attribute_type.name attriblist = attribute_expansion.get_attriblist_str( - attribute_name = attrib_name, - resources = resources, - allocations = allocs) + attribute_name=attrib_name, resources=resources, allocations=allocs + ) if not attriblist: # We do not have an attriblist, return raw_value return raw_value expanded = attribute_expansion.expand_attribute( - raw_value = raw_value, - attribute_name = attrib_name, - attriblist_string = attriblist, - resources = resources, - allocations = allocs) + raw_value=raw_value, + attribute_name=attrib_name, + attriblist_string=attriblist, + resources=resources, + allocations=allocs, + ) return expanded - + + class AllocationAttributeUsage(TimeStampedModel): - """ Allocation attribute usage indicates the usage of an allocation attribute. - + """Allocation attribute usage indicates the usage of an allocation attribute. + Attributes: allocation_attribute (AllocationAttribute): links the usage to its allocation attribute value (float): usage value of the allocation attribute """ - allocation_attribute = models.OneToOneField( - AllocationAttribute, on_delete=models.CASCADE, primary_key=True) + allocation_attribute = models.OneToOneField(AllocationAttribute, on_delete=models.CASCADE, primary_key=True) value = models.FloatField(default=0) history = HistoricalRecords() def __str__(self): - return '{}: {}'.format(self.allocation_attribute.allocation_attribute_type.name, self.value) + return "{}: {}".format(self.allocation_attribute.allocation_attribute_type.name, self.value) + class AllocationUserStatusChoice(TimeStampedModel): - """ An allocation user status choice indicates the status of an allocation user. Examples include Active, Error, and Removed. - + """An allocation user status choice indicates the status of an allocation user. Examples include Active, Error, and Removed. + Attributes: name (str): name of the allocation user status choice """ + class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class AllocationUserStatusChoiceManager(models.Manager): def get_by_natural_key(self, name): @@ -565,9 +692,10 @@ def __str__(self): def natural_key(self): return (self.name,) + class AllocationUser(TimeStampedModel): - """ An allocation user represents a user on the allocation. - + """An allocation user represents a user on the allocation. + Attributes: allocation (Allocation): links user to its allocation user (User): represents the User object of the allocation user @@ -576,34 +704,36 @@ class AllocationUser(TimeStampedModel): allocation = models.ForeignKey(Allocation, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) - status = models.ForeignKey(AllocationUserStatusChoice, on_delete=models.CASCADE, - verbose_name='Allocation User Status') + status = models.ForeignKey( + AllocationUserStatusChoice, on_delete=models.CASCADE, verbose_name="Allocation User Status" + ) history = HistoricalRecords() def is_active(self): """Helper function returns True if allocation user status == Active and - allocation status is one of the accepted active states where users - should be considered active and have actions taken on them (i.e. - groups added, accounts created in other systems, etc.)""" + allocation status is one of the accepted active states where users + should be considered active and have actions taken on them (i.e. + groups added, accounts created in other systems, etc.)""" active_allocation_statuses = [ - 'Active', - 'Renewal Requested', + "Active", + "Renewal Requested", ] - return self.status.name == 'Active' and self.allocation.status.name in active_allocation_statuses + return self.status.name == "Active" and self.allocation.status.name in active_allocation_statuses def __str__(self): - return '%s' % (self.user) + return "%s" % (self.user) class Meta: - verbose_name_plural = 'Allocation User Status' - unique_together = ('user', 'allocation') + verbose_name_plural = "Allocation User Status" + unique_together = ("user", "allocation") + class AllocationAccount(TimeStampedModel): - """ An allocation account + """An allocation account #come back to - + Attributes: user (User): represents the User object of the project user name (str): @@ -616,11 +746,14 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class AllocationChangeStatusChoice(TimeStampedModel): - """ An allocation change status choice represents statuses displayed when a user changes their allocation status (for allocations that have their is_changeable attribute set to True). Examples include Expired and Payment Pending. - + """An allocation change status choice represents statuses displayed when a user changes their allocation status (for allocations that have their is_changeable attribute set to True). Examples include Expired and Payment Pending. + Attributes: name (str): status name """ @@ -631,11 +764,14 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class AllocationChangeRequest(TimeStampedModel): - """ An allocation change request represents a request from a PI or manager to change their allocation. - + """An allocation change request represents a request from a PI or manager to change their allocation. + Attributes: allocation (Allocation): represents the allocation to change status (AllocationStatusChoice): represents the allocation status of the changed allocation @@ -644,9 +780,11 @@ class AllocationChangeRequest(TimeStampedModel): notes (str): represents notes for users changing allocations """ - allocation = models.ForeignKey(Allocation, on_delete=models.CASCADE,) - status = models.ForeignKey( - AllocationChangeStatusChoice, on_delete=models.CASCADE, verbose_name='Status') + allocation = models.ForeignKey( + Allocation, + on_delete=models.CASCADE, + ) + status = models.ForeignKey(AllocationChangeStatusChoice, on_delete=models.CASCADE, verbose_name="Status") end_date_extension = models.IntegerField(blank=True, null=True) justification = models.TextField() notes = models.CharField(max_length=512, blank=True, null=True) @@ -667,13 +805,17 @@ def get_parent_resource(self): def __str__(self): return "%s (%s)" % (self.get_parent_resource.name, self.allocation.project.pi) + def get_absolute_url(self): + return reverse("allocation-change-detail", kwargs={"pk": self.pk}) + + class AllocationAttributeChangeRequest(TimeStampedModel): - """ An allocation attribute change request represents a request from a PI/ manager to change their allocation attribute. - + """An allocation attribute change request represents a request from a PI/ manager to change their allocation attribute. + Attributes: allocation_change_request (AllocationChangeRequest): links the change request from which this attribute change is derived allocation_attribute (AllocationAttribute): represents the allocation_attribute to change - new_value (str): new value of allocation attribute + new_value (str): new value of allocation attribute """ allocation_change_request = models.ForeignKey(AllocationChangeRequest, on_delete=models.CASCADE) @@ -682,4 +824,4 @@ class AllocationAttributeChangeRequest(TimeStampedModel): history = HistoricalRecords() def __str__(self): - return '%s' % (self.allocation_attribute.allocation_attribute_type.name) + return "%s" % (self.allocation_attribute.allocation_attribute_type.name) diff --git a/coldfront/core/allocation/signals.py b/coldfront/core/allocation/signals.py index 1c078f0a4b..f9926de57c 100644 --- a/coldfront/core/allocation/signals.py +++ b/coldfront/core/allocation/signals.py @@ -1,16 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import django.dispatch allocation_new = django.dispatch.Signal() - #providing_args=["allocation_pk"] +# providing_args=["allocation_pk"] allocation_activate = django.dispatch.Signal() - #providing_args=["allocation_pk"] +# providing_args=["allocation_pk"] allocation_disable = django.dispatch.Signal() - #providing_args=["allocation_pk"] +# providing_args=["allocation_pk"] allocation_activate_user = django.dispatch.Signal() - #providing_args=["allocation_user_pk"] +# providing_args=["allocation_user_pk"] allocation_remove_user = django.dispatch.Signal() - #providing_args=["allocation_user_pk"] +# providing_args=["allocation_user_pk"] allocation_change_approved = django.dispatch.Signal() - #providing_args=["allocation_pk", "allocation_change_pk"] +# providing_args=["allocation_pk", "allocation_change_pk"] + +allocation_change_created = django.dispatch.Signal() +# providing_args=["allocation_pk", "allocation_change_pk"] + +allocation_attribute_changed = django.dispatch.Signal() +# providing_args=["attribute_pk", "allocation_pk"] diff --git a/coldfront/core/allocation/tasks.py b/coldfront/core/allocation/tasks.py index e1a37fc057..d9e422d4f1 100644 --- a/coldfront/core/allocation/tasks.py +++ b/coldfront/core/allocation/tasks.py @@ -1,9 +1,13 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime + # import the logging library import logging -from coldfront.core.allocation.models import (Allocation, - AllocationStatusChoice) +from coldfront.core.allocation.models import Allocation, AllocationStatusChoice from coldfront.core.user.models import User from coldfront.core.utils.common import import_from_settings from coldfront.core.utils.mail import send_email_template @@ -12,198 +16,234 @@ logger = logging.getLogger(__name__) -CENTER_NAME = import_from_settings('CENTER_NAME') -CENTER_BASE_URL = import_from_settings('CENTER_BASE_URL') -CENTER_PROJECT_RENEWAL_HELP_URL = import_from_settings( - 'CENTER_PROJECT_RENEWAL_HELP_URL') -EMAIL_SENDER = import_from_settings('EMAIL_SENDER') -EMAIL_OPT_OUT_INSTRUCTION_URL = import_from_settings( - 'EMAIL_OPT_OUT_INSTRUCTION_URL') -EMAIL_SIGNATURE = import_from_settings('EMAIL_SIGNATURE') +CENTER_NAME = import_from_settings("CENTER_NAME") +CENTER_BASE_URL = import_from_settings("CENTER_BASE_URL") +CENTER_PROJECT_RENEWAL_HELP_URL = import_from_settings("CENTER_PROJECT_RENEWAL_HELP_URL") +EMAIL_SENDER = import_from_settings("EMAIL_SENDER") +EMAIL_OPT_OUT_INSTRUCTION_URL = import_from_settings("EMAIL_OPT_OUT_INSTRUCTION_URL") +EMAIL_SIGNATURE = import_from_settings("EMAIL_SIGNATURE") EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS = import_from_settings( - 'EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS', [7, ]) + "EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS", + [ + 7, + ], +) -EMAIL_ADMINS_ON_ALLOCATION_EXPIRE = import_from_settings('EMAIL_ADMINS_ON_ALLOCATION_EXPIRE') -EMAIL_ADMIN_LIST = import_from_settings('EMAIL_ADMIN_LIST') +EMAIL_ADMINS_ON_ALLOCATION_EXPIRE = import_from_settings("EMAIL_ADMINS_ON_ALLOCATION_EXPIRE") +EMAIL_ADMIN_LIST = import_from_settings("EMAIL_ADMIN_LIST") -def update_statuses(): +EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = import_from_settings("EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT") - expired_status_choice = AllocationStatusChoice.objects.get( - name='Expired') + +def update_statuses(): + expired_status_choice = AllocationStatusChoice.objects.get(name="Expired") allocations_to_expire = Allocation.objects.filter( - status__name__in=['Active','Payment Pending','Payment Requested', 'Unpaid',], end_date__lt=datetime.datetime.now().date()) + status__name__in=[ + "Active", + "Payment Pending", + "Payment Requested", + "Unpaid", + ], + end_date__lt=datetime.datetime.now().date(), + ) for sub_obj in allocations_to_expire: sub_obj.status = expired_status_choice sub_obj.save() - logger.info('Allocations set to expired: {}'.format( - allocations_to_expire.count())) + logger.info("Allocations set to expired: {}".format(allocations_to_expire.count())) + + +def send_eula_reminders(): + for allocation in Allocation.objects.all(): + if not allocation.get_eula(): + continue + + email_receivers = allocation.get_user_emails(status_name="PendingEULA") + + if not email_receivers: + continue + + template_context = { + "resource": allocation.get_parent_resource, + "url": f"{CENTER_BASE_URL.strip('/')}/{'allocation'}/{allocation.pk}/review-eula", + } + + send_email_template( + f"Reminder: Agree to EULA for {allocation}", + "email/allocation_eula_reminder.txt", + template_context, + email_receivers, + ) + logger.debug(f"Allocation(s) EULA reminder sent to users {email_receivers}.") def send_expiry_emails(): - #Allocations expiring soon + # TODO: cleanup + # Allocations expiring soon for user in User.objects.all(): projectdict = {} expirationdict = {} email_receiver_list = [] for days_remaining in sorted(set(EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS)): + expring_in_days = (datetime.datetime.today() + datetime.timedelta(days=days_remaining)).date() - expring_in_days = (datetime.datetime.today( - ) + datetime.timedelta(days=days_remaining)).date() - for allocationuser in user.allocationuser_set.all(): allocation = allocationuser.allocation - if (((allocation.status.name in ['Active', 'Payment Pending', 'Payment Requested', 'Unpaid']) and (allocation.end_date == expring_in_days))): - - project_url = f'{CENTER_BASE_URL.strip("/")}/{"project"}/{allocation.project.pk}/' + if (allocation.status.name in ["Active", "Payment Pending", "Payment Requested", "Unpaid"]) and ( + allocation.end_date == expring_in_days + ): + project_url = f"{CENTER_BASE_URL.strip('/')}/{'project'}/{allocation.project.pk}/" - if (allocation.status.name in ['Payment Pending', 'Payment Requested', 'Unpaid']): - allocation_renew_url = f'{CENTER_BASE_URL.strip("/")}/{"allocation"}/{allocation.pk}/' + if allocation.status.name in ["Payment Pending", "Payment Requested", "Unpaid"]: + allocation_renew_url = f"{CENTER_BASE_URL.strip('/')}/{'allocation'}/{allocation.pk}/" else: - allocation_renew_url = f'{CENTER_BASE_URL.strip("/")}/{"allocation"}/{allocation.pk}/{"renew"}/' + allocation_renew_url = f"{CENTER_BASE_URL.strip('/')}/{'allocation'}/{allocation.pk}/{'renew'}/" resource_name = allocation.get_parent_resource.name template_context = { - 'center_name': CENTER_NAME, - 'expring_in_days': days_remaining, - 'project_dict': projectdict, - 'expiration_dict': expirationdict, - 'expiration_days': sorted(set(EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS)), - 'project_renewal_help_url': CENTER_PROJECT_RENEWAL_HELP_URL, - 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL, - 'signature': EMAIL_SIGNATURE + "expring_in_days": days_remaining, + "project_dict": projectdict, + "expiration_dict": expirationdict, + "expiration_days": sorted(set(EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS)), + "project_renewal_help_url": CENTER_PROJECT_RENEWAL_HELP_URL, + "opt_out_instruction_url": EMAIL_OPT_OUT_INSTRUCTION_URL, + "signature": EMAIL_SIGNATURE, } - + expire_notification = allocation.allocationattribute_set.filter( - allocation_attribute_type__name='EXPIRE NOTIFICATION').first() - if expire_notification and expire_notification.value == 'No': + allocation_attribute_type__name="EXPIRE NOTIFICATION" + ).first() + if expire_notification and expire_notification.value == "No": continue cloud_usage_notification = allocation.allocationattribute_set.filter( - allocation_attribute_type__name='CLOUD_USAGE_NOTIFICATION').first() - if cloud_usage_notification and cloud_usage_notification.value == 'No': + allocation_attribute_type__name="CLOUD_USAGE_NOTIFICATION" + ).first() + if cloud_usage_notification and cloud_usage_notification.value == "No": continue - for projectuser in allocation.project.projectuser_set.filter(user=user, status__name='Active'): - if ((projectuser.enable_notifications) and - (allocationuser.user == user and allocationuser.status.name == 'Active')): - - if (user.email not in email_receiver_list): + for projectuser in allocation.project.projectuser_set.filter(user=user, status__name="Active"): + if (projectuser.enable_notifications) and ( + allocationuser.user == user and allocationuser.status.name == "Active" + ): + if user.email not in email_receiver_list: email_receiver_list.append(user.email) if days_remaining not in expirationdict: expirationdict[days_remaining] = [] - expirationdict[days_remaining].append((project_url, allocation_renew_url, resource_name)) + expirationdict[days_remaining].append( + (project_url, allocation_renew_url, resource_name) + ) else: - expirationdict[days_remaining].append((project_url, allocation_renew_url, resource_name)) + expirationdict[days_remaining].append( + (project_url, allocation_renew_url, resource_name) + ) if allocation.project.title not in projectdict: - projectdict[allocation.project.title] = (project_url, allocation.project.pi.username,) - - if email_receiver_list: + projectdict[allocation.project.title] = ( + project_url, + allocation.project.pi.username, + ) - send_email_template(f'Your access to {CENTER_NAME}\'s resources is expiring soon', - 'email/allocation_expiring.txt', - template_context, - EMAIL_SENDER, - email_receiver_list - ) + if email_receiver_list: + send_email_template( + f"Your access to {CENTER_NAME}'s resources is expiring soon", + "email/allocation_expiring.txt", + template_context, + email_receiver_list, + ) - logger.debug(f'Allocation(s) expiring in soon, email sent to user {user}.') + logger.debug(f"Allocation(s) expiring in soon, email sent to user {user}.") - #Allocations expired + # Allocations expired admin_projectdict = {} admin_allocationdict = {} for user in User.objects.all(): projectdict = {} allocationdict = {} email_receiver_list = [] - + expring_in_days = (datetime.datetime.today() + datetime.timedelta(days=-1)).date() - + for allocationuser in user.allocationuser_set.all(): allocation = allocationuser.allocation - if (allocation.end_date == expring_in_days): - - project_url = f'{CENTER_BASE_URL.strip("/")}/{"project"}/{allocation.project.pk}/' + if allocation.end_date == expring_in_days: + project_url = f"{CENTER_BASE_URL.strip('/')}/{'project'}/{allocation.project.pk}/" - allocation_renew_url = f'{CENTER_BASE_URL.strip("/")}/{"allocation"}/{allocation.pk}/{"renew"}/' + allocation_renew_url = f"{CENTER_BASE_URL.strip('/')}/{'allocation'}/{allocation.pk}/{'renew'}/" - allocation_url = f'{CENTER_BASE_URL.strip("/")}/{"allocation"}/{allocation.pk}/' + allocation_url = f"{CENTER_BASE_URL.strip('/')}/{'allocation'}/{allocation.pk}/" resource_name = allocation.get_parent_resource.name template_context = { - 'center_name': CENTER_NAME, - 'project_dict': projectdict, - 'allocation_dict': allocationdict, - 'project_renewal_help_url': CENTER_PROJECT_RENEWAL_HELP_URL, - 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL, - 'signature': EMAIL_SIGNATURE + "project_dict": projectdict, + "allocation_dict": allocationdict, + "project_renewal_help_url": CENTER_PROJECT_RENEWAL_HELP_URL, + "opt_out_instruction_url": EMAIL_OPT_OUT_INSTRUCTION_URL, + "signature": EMAIL_SIGNATURE, } expire_notification = allocation.allocationattribute_set.filter( - allocation_attribute_type__name='EXPIRE NOTIFICATION').first() - - for projectuser in allocation.project.projectuser_set.filter(user=user, status__name='Active'): - if ((projectuser.enable_notifications) and - (allocationuser.user == user and allocationuser.status.name == 'Active')): - - if expire_notification and expire_notification.value == 'Yes': - - if (user.email not in email_receiver_list): + allocation_attribute_type__name="EXPIRE NOTIFICATION" + ).first() + + for projectuser in allocation.project.projectuser_set.filter(user=user, status__name="Active"): + if (projectuser.enable_notifications) and ( + allocationuser.user == user and allocationuser.status.name == "Active" + ): + if expire_notification and expire_notification.value == "Yes": + if user.email not in email_receiver_list: email_receiver_list.append(user.email) if project_url not in allocationdict: - allocationdict[project_url] = [] - allocationdict[project_url].append({allocation_renew_url : resource_name}) + allocationdict[project_url] = [] + allocationdict[project_url].append({allocation_renew_url: resource_name}) else: - if {allocation_renew_url : resource_name} not in allocationdict[project_url]: - allocationdict[project_url].append({allocation_renew_url : resource_name}) + if {allocation_renew_url: resource_name} not in allocationdict[project_url]: + allocationdict[project_url].append({allocation_renew_url: resource_name}) if allocation.project.title not in projectdict: projectdict[allocation.project.title] = (project_url, allocation.project.pi.username) if EMAIL_ADMINS_ON_ALLOCATION_EXPIRE: - if project_url not in admin_allocationdict: - admin_allocationdict[project_url] = [] - admin_allocationdict[project_url].append({allocation_url : resource_name}) + admin_allocationdict[project_url] = [] + admin_allocationdict[project_url].append({allocation_url: resource_name}) else: - if {allocation_url : resource_name} not in admin_allocationdict[project_url]: - admin_allocationdict[project_url].append({allocation_url : resource_name}) + if {allocation_url: resource_name} not in admin_allocationdict[project_url]: + admin_allocationdict[project_url].append({allocation_url: resource_name}) if allocation.project.title not in admin_projectdict: - admin_projectdict[allocation.project.title] = (project_url, allocation.project.pi.username) + admin_projectdict[allocation.project.title] = ( + project_url, + allocation.project.pi.username, + ) - if email_receiver_list: + send_email_template( + "Your access to resource(s) have expired", + "email/allocation_expired.txt", + template_context, + email_receiver_list, + ) - send_email_template('Your access to resource(s) have expired', - 'email/allocation_expired.txt', - template_context, - EMAIL_SENDER, - email_receiver_list - ) - - logger.debug(f'Allocation(s) expired email sent to user {user}.') + logger.debug(f"Allocation(s) expired email sent to user {user}.") if EMAIL_ADMINS_ON_ALLOCATION_EXPIRE: - if admin_projectdict: - admin_template_context = { - 'project_dict': admin_projectdict, - 'allocation_dict': admin_allocationdict, - 'signature': EMAIL_SIGNATURE - } - - send_email_template('Allocation(s) have expired', - 'email/admin_allocation_expired.txt', - admin_template_context, - EMAIL_SENDER, - [EMAIL_ADMIN_LIST,] - ) \ No newline at end of file + "project_dict": admin_projectdict, + "allocation_dict": admin_allocationdict, + "signature": EMAIL_SIGNATURE, + } + + send_email_template( + "Allocation(s) have expired", + "email/admin_allocation_expired.txt", + admin_template_context, + [EMAIL_ADMIN_LIST], + ) diff --git a/coldfront/core/allocation/templates/allocation/allocation_account_list.html b/coldfront/core/allocation/templates/allocation/allocation_account_list.html index 0a9373d260..c24c1db737 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_account_list.html +++ b/coldfront/core/allocation/templates/allocation/allocation_account_list.html @@ -11,7 +11,7 @@ {% block content %}

Allocation Accounts

-
+
diff --git a/coldfront/core/allocation/templates/allocation/allocation_add_users.html b/coldfront/core/allocation/templates/allocation/allocation_add_users.html index 81d56c857d..41031e202e 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_add_users.html +++ b/coldfront/core/allocation/templates/allocation/allocation_add_users.html @@ -22,7 +22,7 @@

Add users to allocation for project: {{allocation.project.title}}

- + # Username @@ -47,7 +47,7 @@

Add users to allocation for project: {{allocation.project.title}}

{{ formset.management_form }}
- Back to Allocation @@ -63,16 +63,4 @@

Add users to allocation for project: {{allocation.project.title}}

{% endif %} - {% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_allocationattribute_delete.html b/coldfront/core/allocation/templates/allocation/allocation_allocationattribute_delete.html index c3ceb29512..0259b5084f 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_allocationattribute_delete.html +++ b/coldfront/core/allocation/templates/allocation/allocation_allocationattribute_delete.html @@ -22,7 +22,7 @@

Delete allocation attributes from allocation for project: {{allocation.proje - + Name Value @@ -54,20 +54,8 @@

Delete allocation attributes from allocation for project: {{allocation.proje Back to Allocation
- No users to remove! + No attributes to remove!
{% endif %} - {% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_attribute_edit.html b/coldfront/core/allocation/templates/allocation/allocation_attribute_edit.html new file mode 100644 index 0000000000..cac33211a2 --- /dev/null +++ b/coldfront/core/allocation/templates/allocation/allocation_attribute_edit.html @@ -0,0 +1,91 @@ +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load static %} + +{% block title %} Allocation Change Detail {% endblock %} + +{% block content %} +

+ Edit attributes for {{ allocation.get_parent_resource }} for project: {{ allocation.project.title }} +

+
+
+
+
+

+ Allocation + Attributes +

+
+
+ {% if attributes %} {% csrf_token %} +
+ + + + + + + + + {% for form in formset %} + + + + + {% endfor %} + +
AttributeSet New Value
{{form.name.value}} + {{form.value}} + + + Value changed + +
+
+ {% else %} + + {% endif %} + + {% if attributes %} + + {% endif %} + Cancel + {{ formset.management_form }} +
+
+
+ +{% endblock %} + +{% block javascript %} +{{ block.super }} + +{% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_change.html b/coldfront/core/allocation/templates/allocation/allocation_change.html index 4fd7def34f..fd9c27a1db 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_change.html +++ b/coldfront/core/allocation/templates/allocation/allocation_change.html @@ -1,137 +1,135 @@ -{% extends "common/base.html" %} -{% load crispy_forms_tags %} -{% load static %} - - -{% block title %} -Request Allocation Change -{% endblock %} - - -{% block content %} - -

Request change to {{ allocation.get_parent_resource }} for project: {{ allocation.project.title }}

-
- -

- Request changes to an existing allocation using the form below. For each change - you must provide a justification. -

- -
-
-
-

Allocation Information

-
- -
- {% csrf_token %} -
- - - - - - - - - - - - - - - - - - - - - - - - - - {% if allocation.is_changeable %} - - - - - {% endif %} - - - - - - - - - - - - - - {% if allocation.is_locked %} - - - {% else %} - - - {% endif %} - -
Project:{{ allocation.project }}
Resource{{ allocation.resources.all|pluralize }} in allocation:{{ allocation.get_resources_as_string }}
Justification:{{ allocation.justification }}
Status:{{ allocation.status }}
Start Date:{{ allocation.start_date }}
End Date: - {{ allocation.end_date }} - {% if allocation.is_locked and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} - - Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}} - Not renewable - - {% endif %} -
Request End Date Extension: - {{ form.end_date_extension }} -
Description:{{allocation.description|default_if_none:""}}
Created:{{ allocation.created|date:"M. d, Y" }}
Last Modified:{{ allocation.modified|date:"M. d, Y" }}
LockedUnlocked
-
-
-
- - {% if formset %} -
-
-

Allocation Attributes

-
-
-
- - - - - - - - - - {% for form in formset %} - - - - - - {% endfor %} - -
AttributeCurrent ValueRequest New Value
{{form.name.value}}{{form.value.value}}{{form.new_value}}
-
- {{ formset.management_form }} -
-
- {% endif %} - -
- {{form.justification | as_crispy_field }} - - Back to - Allocation
-
-
- - -{% endblock %} +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load static %} + + +{% block title %} +Request Allocation Change +{% endblock %} + + +{% block content %} + +

Request change to {{ allocation.get_parent_resource }} for project: {{ allocation.project.title }}

+
+ +

+ Request changes to an existing allocation using the form below. For each change + you must provide a justification. +

+ +
+
+
+

Allocation Information

+
+ +
+ {% csrf_token %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {% if allocation.is_changeable %} + + + + + {% endif %} + + + + + + + + + + + + + + {% if allocation.is_locked %} + + + {% else %} + + + {% endif %} + +
Project:{{ allocation.project }}
Resource{{ allocation.resources.all|pluralize }} in allocation:{{ allocation.get_resources_as_string }}
Justification:{{ allocation.justification }}
Status:{{ allocation.status }}
Start Date:{{ allocation.start_date }}
End Date: + {{ allocation.end_date }} + {% if allocation.is_locked and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} + + Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}} - Not renewable + + {% endif %} +
Request End Date Extension: + {{ form.end_date_extension }} +
Description:{{allocation.description|default_if_none:""}}
Created:{{ allocation.created|date:"M. d, Y" }}
Last Modified:{{ allocation.modified|date:"M. d, Y" }}
LockedUnlocked
+
+
+
+ + {% if formset %} +
+
+

Allocation Attributes

+
+
+
+ + + + + + + + + + {% for form in formset %} + + + + + + {% endfor %} + +
AttributeCurrent ValueRequest New Value
{{form.name.value}}{{form.value.value}}{{form.new_value}}
+
+ {{ formset.management_form }} +
+
+ {% endif %} + +
+ {{form.justification | as_crispy_field }} + + Back to + Allocation
+
+
+ +{% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_change_detail.html b/coldfront/core/allocation/templates/allocation/allocation_change_detail.html index cf12499131..ad89c76813 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_change_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_change_detail.html @@ -1,219 +1,225 @@ -{% extends "common/base.html" %} -{% load crispy_forms_tags %} -{% load static %} - - -{% block title %} -Allocation Change Detail -{% endblock %} - - -{% block content %} - -

Change requested to {{ allocation_change.allocation.get_parent_resource }} for project: {{ allocation_change.allocation.project.title }}

-
- - {% if allocation_change.status.name == "Approved" %} - - {% elif allocation_change.status.name == "Denied"%} - - {% else %} - - {% endif %} - -
-
-
-

Allocation Information

-
- -
- {% csrf_token %} -
- - - - - - - - - - - - - - - - - - - - - - - - - - {% if allocation_change.allocation.is_changeable %} - - - - - {% endif %} - - - - - - - - - - - - - - {% if allocation_change.allocation.is_locked %} - - - {% else %} - - - {% endif %} - -
Project:{{ allocation_change.allocation.project }}
Resource{{ allocation_change.allocation.resources.all|pluralize }} in allocation:{{ allocation_change.allocation.get_resources_as_string }}
Justification:{{ allocation_change.allocation.justification }}
Status:{{ allocation_change.allocation.status }}
Start Date:{{ allocation_change.allocation.start_date }}
End Date: - {{ allocation_change.allocation.end_date }} - {% if allocation_change.allocation.is_locked and allocation_change.allocation.status.name == 'Approved' and allocation_change.allocation.expires_in <= 60 and allocation_change.allocation.expires_in >= 0 %} - - Expires in {{allocation_change.allocation.expires_in}} day{{allocation_change.allocation.expires_in|pluralize}} - Not renewable - - {% endif %} -
Requested End Date Extension: - {{allocation_change_form.end_date_extension}} -
Description:{{ allocation_change.allocation.description|default_if_none:"" }}
Change Requested:{{ allocation_change.created|date:"M. d, Y" }}
Change Last Modified:{{ allocation_change.modified|date:"M. d, Y" }}
LockedUnlocked
-
-
-
- - -
-
-

Allocation Attributes

-
-
- {% if attribute_changes %} -
- - - - - {% if allocation_change.status.name == 'Pending' %} - - {% endif %} - - - - - {% for form in formset %} - - - {% if allocation_change.status.name == 'Pending' %} - - {% if request.user.is_superuser %} - - {% else %} - - {% endif %} - {% else %} - {% if form.new_value.value == '' %} - - {% else %} - - {% endif %} - {% endif %} - - {% endfor %} - -
AttributeCurrent ValueRequested New Value
{{form.name.value}}{{form.value.value}} - {{form.new_value}} - - - - {{form.new_value.value}}None{{form.new_value.value}}
-
- {% else %} - - {% endif %} - {{ formset.management_form }} -
-
- -

{{allocation_change_form.justification | as_crispy_field}}

- -
- - {% if request.user.is_superuser %} -
-
-

Actions

-
-
- - - {% csrf_token %} - {{note_form.notes | as_crispy_field}} -
- {% if allocation_change.status.name == 'Pending' %} - - - {% endif %} - -
-
-
-
- {% endif %} - - - - - View Allocation - - {% if request.user.is_superuser %} - - See all allocation change requests - - {% endif %} -
- - -{% endblock %} - +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load static %} + + +{% block title %} +Allocation Change Detail +{% endblock %} + + +{% block content %} + +

Change requested to {{ allocation_change.allocation.get_parent_resource }} for project: {{ allocation_change.allocation.project.title }}

+
+ + {% if allocation_change.status.name == "Approved" %} + + {% elif allocation_change.status.name == "Denied"%} + + {% else %} + + {% endif %} + +
+
+
+

Allocation Information

+
+ +
+ {% csrf_token %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if allocation_change.allocation.is_changeable %} + + + + + {% endif %} + + + + + + + + + + + + + + {% if allocation_change.allocation.is_locked %} + + + {% else %} + + + {% endif %} + +
Project:{{ allocation_change.allocation.project }}
Allocation ID:{{ allocation_change.allocation.pk }}
Resource{{ allocation_change.allocation.resources.all|pluralize }} in allocation:{{ allocation_change.allocation.get_resources_as_string }}
Justification:{{ allocation_change.allocation.justification }}
Status:{{ allocation_change.allocation.status }}
Start Date:{{ allocation_change.allocation.start_date }}
End Date: + {{ allocation_change.allocation.end_date }} + {% if allocation_change.allocation.is_locked and allocation_change.allocation.status.name == 'Approved' and allocation_change.allocation.expires_in <= 60 and allocation_change.allocation.expires_in >= 0 %} + + Expires in {{allocation_change.allocation.expires_in}} day{{allocation_change.allocation.expires_in|pluralize}} - Not renewable + + {% endif %} +
Requested End Date Extension: + {{allocation_change_form.end_date_extension}} +
Description:{{ allocation_change.allocation.description|default_if_none:"" }}
Change Requested:{{ allocation_change.created|date:"M. d, Y" }}
Change Last Modified:{{ allocation_change.modified|date:"M. d, Y" }}
LockedUnlocked
+
+
+
+ + +
+
+

Allocation Attributes

+
+
+ {% if attribute_changes %} +
+ + + + + {% if allocation_change.status.name == 'Pending' %} + + {% endif %} + + + + + {% for form in formset %} + + + {% if allocation_change.status.name == 'Pending' %} + + {% if request.user.is_superuser %} + + {% else %} + + {% endif %} + {% else %} + {% if form.new_value.value == '' %} + + {% else %} + + {% endif %} + {% endif %} + + {% endfor %} + +
AttributeCurrent ValueRequested New Value
{{form.name.value}}{{form.value.value}} + {{form.new_value}} + + + + {{form.new_value.value}}None{{form.new_value.value}}
+
+ {% else %} + + {% endif %} + {{ formset.management_form }} +
+
+ +

{{allocation_change_form.justification | as_crispy_field}}

+ +
+ + {% if request.user.is_superuser %} +
+
+

Actions

+
+
+ {% csrf_token %} + {{note_form.notes | as_crispy_field}} +
+ {% if allocation_change.status.name == 'Pending' %} + + + {% endif %} + +
+
+
+ {% endif %} +
+ + + + View Allocation + + {% if request.user.is_superuser %} + + See all allocation change requests + + {% endif %} +
+ +{% endblock %} + +{% block javascript %} +{{ block.super }} + +{% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_change_list.html b/coldfront/core/allocation/templates/allocation/allocation_change_list.html index 7afd32432e..1f8b41f5ca 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_change_list.html +++ b/coldfront/core/allocation/templates/allocation/allocation_change_list.html @@ -1,85 +1,82 @@ -{% extends "common/base.html" %} -{% load crispy_forms_tags %} -{% load common_tags %} -{% load static %} - - -{% block title %} -Allocation Review New and Pending Change Requests -{% endblock %} - - -{% block content %} -

Allocation Change Requests

- -
- -

- For each allocation change request below, there is the option to activate the allocation request and to view the allocation change's detail page. - If a change request is only for an extension to the allocation, they can be approved on this page. However if the change request includes changes to - the allocation's attributes, the request must be reviewed and acted upon in its detail page. -

- -{% if allocation_change_list %} -
- - - - - - - - - - - - - - {% for change in allocation_change_list %} - - - - - - - - - - {% endfor %} - -
#RequestedProject TitlePIResourceExtensionActions
{{change.pk}}{{ change.created|date:"M. d, Y" }}{{change.allocation.project.title|truncatechars:50}}{{change.allocation.project.pi.first_name}} {{change.allocation.project.pi.last_name}} - ({{change.allocation.project.pi.username}}){{change.allocation.get_parent_resource}} - {% if change.end_date_extension == 0 %} - {% else %} {{change.end_date_extension}} days - {% endif %} - -
- {% if change.allocationattributechangerequest_set.all %} - - {% else %} - {% csrf_token %} - - - {% endif %} - Details -
-
-
-{% else %} -
- No new or pending allocation change requests! -
-{% endif %} - - -{% endblock %} +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load common_tags %} +{% load static %} + + +{% block title %} +Allocation Review New and Pending Change Requests +{% endblock %} + + +{% block content %} +

Allocation Change Requests

+ +
+ +

+ For each allocation change request below, there is the option to activate the allocation request and to view the allocation change's detail page. + If a change request is only for an extension to the allocation, they can be approved on this page. However if the change request includes changes to + the allocation's attributes, the request must be reviewed and acted upon in its detail page. +

+ +{% if allocation_change_list %} +
+ + + + + + + + + + + + + + + {% for change in allocation_change_list %} + + + + + + + + + + + {% endfor %} + +
#RequestedProject TitleAllocation IDPIResourceExtensionActions
{{change.pk}}{{ change.created|date:"M. d, Y" }}{{change.allocation.project.title|truncatechars:50}}{{change.allocation.pk}}{{change.allocation.project.pi.first_name}} {{change.allocation.project.pi.last_name}} + ({{change.allocation.project.pi.username}}){{change.allocation.get_parent_resource}} + {% if change.end_date_extension == 0 %} + {% else %} {{change.end_date_extension}} days + {% endif %} + +
+ {% if change.allocationattributechangerequest_set.all %} + + {% else %} + {% csrf_token %} + + + {% endif %} + Details +
+
+
+{% else %} +
+ No new or pending allocation change requests! +
+{% endif %} + +{% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_create.html b/coldfront/core/allocation/templates/allocation/allocation_create.html index aba1fef3bf..2aa4a1d3fb 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_create.html +++ b/coldfront/core/allocation/templates/allocation/allocation_create.html @@ -13,7 +13,7 @@

Request New Allocation
Project: {{ project.title }}


-

The following {% settings_value 'CENTER_NAME' %} +

The following {% settings_value 'CENTER_NAME' %} resources are available to request for this project. If you need access to more than one of these, please submit a separate allocation request for each resource. For each request you must provide the justification for how you @@ -26,7 +26,7 @@

Request New Allocation
Project: {{ project.title }}

@@ -39,8 +39,8 @@

Request New Allocation
Project: {{ project.title }}

+{{ resources_form_default_quantities|json_script:"resources-form-default-quantities" }} +{{ resources_form_descriptions|json_script:"resources-form-description" }} +{{ resources_form_label_texts|json_script:"resources-form-label-texts" }} +{{ resources_with_accounts|json_script:"resources-with-accounts" }} +{{ resources_with_eula|json_script:"resources-with-eula" }} + +{% endblock %} + +{% block javascript %} +{{ block.super }} {% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_delete_invoice_note.html b/coldfront/core/allocation/templates/allocation/allocation_delete_invoice_note.html index c56f6cd01c..911cea21fb 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_delete_invoice_note.html +++ b/coldfront/core/allocation/templates/allocation/allocation_delete_invoice_note.html @@ -21,7 +21,7 @@

Delete invoice notes for allocation to {{allocation.get_resources_as_string} - + Note Author @@ -49,15 +49,4 @@

Delete invoice notes for allocation to {{allocation.get_resources_as_string} - {% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_detail.html b/coldfront/core/allocation/templates/allocation/allocation_detail.html index b459f04882..59faa0762e 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_detail.html @@ -40,10 +40,10 @@

Allocation Information

{% else %} -

Allocation Information

+

Allocation Information

{% endif %} - +
{% csrf_token %} @@ -54,16 +54,18 @@

Allocation Information

{{ allocation.project }} - Resource{{ allocation.resources.all|pluralize }} in allocation: + {% with allocation.get_resources_as_list as resources_as_list %} + Resource{{ resources_as_list|pluralize }} in allocation: - {% if allocation.get_resources_as_list %} - {% for resource in allocation.get_resources_as_list %} + {% if resources_as_list %} + {% for resource in resources_as_list %} {{ resource }}
{% endfor %} {% else %} None {% endif %} + {% endwith %} {% if request.user.is_superuser %} @@ -104,12 +106,12 @@

Allocation Information

{{ allocation.end_date }} {% endif %} {% if allocation.is_locked and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} - + Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}} - Not renewable - {% elif is_allowed_to_update_project and ALLOCATION_ENABLE_ALLOCATION_RENEWAL and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} + {% elif is_allowed_to_update_project and settings.ALLOCATION_ENABLE_ALLOCATION_RENEWAL and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} - + Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}} - Click to renew @@ -161,10 +163,10 @@

Allocation Information

{% if request.user.is_superuser %} -
+
{% if allocation.status.name == 'New' or allocation.status.name == 'Renewal Requested' %} - - + + {% endif %} {% endif %} @@ -173,12 +175,52 @@

Allocation Information

+{% if eulas %} +
+
+

EULA Agreements

+
+
+
+ + + + + + + + + + + + + +
ResourceEULA
+ {{res_obj}}
+
+ {{eulas}} +
+ {% if user_in_allocation %} + + {% endif %} +
+
+
+ {% endif %} + {% if attributes or attributes_with_usage or request.user.is_superuser %}

Allocation Attributes

-
+
{% if request.user.is_superuser %} + {% if attributes %} + + Edit Allocation Attributes + + {% endif %} Add Allocation Attribute @@ -222,10 +264,12 @@

Alloc {% if attributes_with_usage %}
{% for attribute in attributes_with_usage %} -
+

{{attribute}}

-
+
+ +
{% endfor %} @@ -234,16 +278,28 @@

{{attribute}}

{% endif %} +{% if display_slurm_help %} + {% include "slurm/slurm_help_div.html" %} +{% endif %} + +{% if not user_is_pending %}
-

Allocation Change Requests

{{allocation_changes.count}} +

Allocation Change Requests

{{allocation_changes.count}} +
+ {% if request.user.is_superuser and allocation.is_changeable and not allocation.is_locked and is_allowed_to_update_project and allocation.status.name in 'Active, Renewal Requested, Payment Pending, Payment Requested, Paid' %} + + Request Change + + {% endif %} +
- +
{% if allocation_changes %}
- +
@@ -263,13 +319,14 @@

Alloc {% else %}

{% endif %} - {% if change_request.notes %} {% else %} {% endif %} - + {% if can_edit_allocation_changes %} + + {% endif %} {% endfor %} @@ -288,8 +345,8 @@

Alloc

Users in Allocation

- {{allocation_users.count}} -
+ {{allocation_users.count}} +
{% if allocation.project.status.name != 'Archived' and is_allowed_to_update_project and allocation.status.name in 'Active,New,Renewal Requested' %} Add Users @@ -302,7 +359,7 @@

Users in Al

-

Date Requested{{ change_request.status.name }}{{change_request.notes}}EditEdit
+
@@ -310,7 +367,7 @@

Users in Al

- + @@ -322,7 +379,9 @@

Users in Al

{% if user.status.name == 'Active' %} - {% elif user.status.name == 'Denied' or user.status.name == 'Error' %} + {% elif user.status.name == 'PendingEULA' %} + + {% elif user.status.name == 'Denied' or user.status.name == 'Error' or user.status.name == 'DeclinedEULA' %} {% else %} @@ -340,8 +399,8 @@

Users in Al

Notifications

- {{notes.count}} -
+{% endif %} - {% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_invoice_detail.html b/coldfront/core/allocation/templates/allocation/allocation_invoice_detail.html index a47c6079b4..3d70b20d1f 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_invoice_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_invoice_detail.html @@ -83,7 +83,7 @@

Allocation Information

UsernameLast Name Email StatusLast ModifiedLast Modified
{{ user.user.email }}{{ user.status.name }}{{ user.status.name }}{{ user.status.name }}{{ user.status.name }}
{% if request.user.is_superuser or perms.allocation.can_manage_invoice%} - + {% endif %}
@@ -93,8 +93,8 @@

Allocation Information

Notes from Staff

- {{allocation.allocationusernote_set.count}} -
+ {{allocation.allocationusernote_set.count}} +
Add Note @@ -121,7 +121,7 @@

Notes from {{ note.note }} {{ note.author.first_name }} {{ note.author.last_name }} ({{ note.author.username }}) {{ note.modified }} - Edit + Edit {% endfor %} @@ -135,4 +135,3 @@

Notes from

{% endblock %} - diff --git a/coldfront/core/allocation/templates/allocation/allocation_invoice_list.html b/coldfront/core/allocation/templates/allocation/allocation_invoice_list.html index 0c12e80525..00baea860a 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_invoice_list.html +++ b/coldfront/core/allocation/templates/allocation/allocation_invoice_list.html @@ -25,7 +25,7 @@

Allocations that require payment

{% for allocation in allocation_list %} - {{allocation.pk}} + {{allocation.pk}} {{allocation.get_resources_as_string}} {{allocation.status}} {{allocation.project.pi.username }} diff --git a/coldfront/core/allocation/templates/allocation/allocation_list.html b/coldfront/core/allocation/templates/allocation/allocation_list.html index 8dcece99c3..b787eebc95 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_list.html +++ b/coldfront/core/allocation/templates/allocation/allocation_list.html @@ -17,12 +17,12 @@

Allocations

-
+
{{ allocation_search_form|crispy }} @@ -44,8 +44,8 @@

Allocations

ID - Sort ID asc - Sort ID desc + Sort ID asc + Sort ID desc Project @@ -53,24 +53,24 @@

Allocations

PI Sort PI asc + class="fas fa-sort-up" aria-hidden="true">Sort PI asc Sort PI desc + class="fas fa-sort-down" aria-hidden="true">
Sort PI desc Resource Name - Sort Resource Name asc - Sort Resource Name desc + Sort Resource Name asc + Sort Resource Name desc Status - Sort Status asc - Sort Status desc + Sort Status asc + Sort Status desc End Date - Sort End Date asc - Sort End Date desc + Sort End Date asc + Sort End Date desc @@ -78,7 +78,7 @@

Allocations

{% for allocation in allocation_list %} {{ allocation.id }} - {{ allocation.project.title|truncatechars:50 }} {{allocation.project.pi.first_name}} {{allocation.project.pi.last_name}} ({{allocation.project.pi.username}}) @@ -90,7 +90,7 @@

Allocations

{% if is_paginated %} Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} -
    +
      {% if page_obj.has_previous %}
    • Previous
    • {% else %} @@ -114,26 +114,4 @@

      Allocations

{% endif %} - {% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_remove_users.html b/coldfront/core/allocation/templates/allocation/allocation_remove_users.html index 3fe8016d75..cd29dd4734 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_remove_users.html +++ b/coldfront/core/allocation/templates/allocation/allocation_remove_users.html @@ -22,7 +22,7 @@

Remove users from allocation for project: {{allocation.project.title}}

- + # Username @@ -48,7 +48,7 @@

Remove users from allocation for project: {{allocation.project.title}}

{{ formset.management_form }}
Back to Allocation @@ -66,16 +66,4 @@

Remove users from allocation for project: {{allocation.project.title}}

{% endif %} - {% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_renew.html b/coldfront/core/allocation/templates/allocation/allocation_renew.html index 1cf6a9e6d6..20e6e614dc 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_renew.html +++ b/coldfront/core/allocation/templates/allocation/allocation_renew.html @@ -77,7 +77,7 @@

Renew allocation to {{allocation.get_parent_resource }} for project: {{alloc
-

By clicking submit you agree to the Terms and Conditions.

+

By clicking submit you agree to the Terms and Conditions.

@@ -89,26 +89,21 @@

Renew allocation to {{allocation.get_parent_resource }} for project: {{alloc

- {% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_request_list.html b/coldfront/core/allocation/templates/allocation/allocation_request_list.html index 6e257e0bcb..a6fa98765d 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_request_list.html +++ b/coldfront/core/allocation/templates/allocation/allocation_request_list.html @@ -14,12 +14,12 @@

Allocation Requests


-

+

For each allocation request below, there is the option to activate the allocation request and to view the allocation's detail page.

-

- By default, activating an allocation will make it active for {{ ALLOCATION_DEFAULT_ALLOCATION_LENGTH }} days. +

+ By default, activating an allocation will make it active for {{ settings.ALLOCATION_DEFAULT_ALLOCATION_LENGTH }} days.

{% if allocation_list %} @@ -32,7 +32,7 @@

Allocation Requests

Project Title PI Resource - {% if PROJECT_ENABLE_PROJECT_REVIEW %} + {% if settings.PROJECT_ENABLE_PROJECT_REVIEW %} Project Review Status {% endif %} Status @@ -43,12 +43,12 @@

Allocation Requests

{% for allocation in allocation_list %} {{allocation.pk}} - {{ allocation.created|date:"M. d, Y" }} + {{allocation_renewal_dates|get_value_from_dict:allocation.pk|default:allocation.created|date:"M. d, Y"}}
{{allocation.project.title|truncatechars:50}} {{allocation.project.pi.first_name}} {{allocation.project.pi.last_name}} ({{allocation.project.pi.username}}) {{allocation.get_parent_resource}} - {% if PROJECT_ENABLE_PROJECT_REVIEW %} + {% if settings.PROJECT_ENABLE_PROJECT_REVIEW %} {{allocation.project|convert_status_to_icon}} {% endif %} {{allocation.status}} @@ -56,8 +56,8 @@

Allocation Requests

{% csrf_token %} - - Details + + Details @@ -71,12 +71,15 @@

Allocation Requests

{% endif %} +{% endblock %} + +{% block javascript %} +{{ block.super }} {% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_review_eula.html b/coldfront/core/allocation/templates/allocation/allocation_review_eula.html new file mode 100644 index 0000000000..70774489b4 --- /dev/null +++ b/coldfront/core/allocation/templates/allocation/allocation_review_eula.html @@ -0,0 +1,70 @@ +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load static %} + + +{% block title %} +Review Allocation EULA +{% endblock %} + + +{% block content %} +{% if allocation.project.status.name == 'Archived' %} + +{% endif %} + + +{% if form.non_field_errors %} + +{% endif %} + +{% if eulas %} +
+
+

EULA Agreements

+
+
+
+ + + + + + + + + + + + + +
ResourceEULA
+ {{res_obj}}
+
+ {{eulas}} +
+ {% if allocation_user_status %} + {% if allocation_user_status == 'PendingEULA' %} +
+ {% csrf_token %} +

In order to access this allocation you must agree to the EULA

+
+ + +
+
+ {% elif allocation_user_status == 'DeclinedEULA' %} +

You declined the EULA for this allocation on {{last_updated}}

+ {% elif allocation_user_status == 'Active' %} +

You accepted the EULA for this allocation on {{last_updated}}

+ {% endif %} + {% endif %} +
+
+
+ {% endif %} +{% endblock %} diff --git a/coldfront/core/allocation/test_models.py b/coldfront/core/allocation/test_models.py deleted file mode 100644 index d41703f491..0000000000 --- a/coldfront/core/allocation/test_models.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Unit tests for the allocation models""" - -from django.test import TestCase - -from coldfront.core.test_helpers.factories import AllocationFactory, ResourceFactory - - -class AllocationModelTests(TestCase): - """tests for Allocation model""" - - @classmethod - def setUpTestData(cls): - """Set up project to test model properties and methods""" - cls.allocation = AllocationFactory() - cls.allocation.resources.add(ResourceFactory(name='holylfs07/tier1')) - - def test_allocation_str(self): - """test that allocation str method returns correct string""" - allocation_str = '%s (%s)' % ( - self.allocation.get_parent_resource.name, - self.allocation.project.pi - ) - self.assertEqual(str(self.allocation), allocation_str) diff --git a/coldfront/core/allocation/test_views.py b/coldfront/core/allocation/test_views.py deleted file mode 100644 index b60aa927b2..0000000000 --- a/coldfront/core/allocation/test_views.py +++ /dev/null @@ -1,351 +0,0 @@ -import logging - -from django.test import TestCase -from django.urls import reverse - -from coldfront.core.test_helpers import utils -from coldfront.core.test_helpers.factories import ( - UserFactory, - ProjectFactory, - ResourceFactory, - AllocationFactory, - ProjectUserFactory, - AllocationUserFactory, - AllocationAttributeFactory, - ProjectStatusChoiceFactory, - ProjectUserRoleChoiceFactory, - AllocationStatusChoiceFactory, - AllocationAttributeTypeFactory, - AllocationChangeRequestFactory, -) -from coldfront.core.allocation.models import ( - AllocationChangeRequest, - AllocationChangeStatusChoice, -) - -logging.disable(logging.CRITICAL) - -BACKEND = "django.contrib.auth.backends.ModelBackend" - -class AllocationViewBaseTest(TestCase): - """Base class for allocation view tests.""" - - @classmethod - def setUpTestData(cls): - """Test Data setup for all allocation view tests.""" - AllocationStatusChoiceFactory(name='New') - cls.project = ProjectFactory(status=ProjectStatusChoiceFactory(name='Active')) - cls.allocation = AllocationFactory(project=cls.project) - cls.allocation.resources.add(ResourceFactory(name='holylfs07/tier1')) - # create allocation user that belongs to project - allocation_user = AllocationUserFactory(allocation=cls.allocation) - cls.allocation_user = allocation_user.user - ProjectUserFactory(project=cls.project, user=allocation_user.user) - # create project user that isn't an allocationuser - proj_nonallocation_user = ProjectUserFactory() - cls.proj_nonallocation_user = proj_nonallocation_user.user - cls.admin_user = UserFactory(is_staff=True, is_superuser=True) - manager_role = ProjectUserRoleChoiceFactory(name='Manager') - pi_user = ProjectUserFactory(user=cls.project.pi, project=cls.project, role=manager_role) - cls.pi_user = pi_user.user - # make a quota TB allocation attribute - AllocationAttributeFactory( - allocation=cls.allocation, - value = 100, - allocation_attribute_type=AllocationAttributeTypeFactory(name='Storage Quota (TB)'), - ) - - def allocation_access_tstbase(self, url): - """Test basic access control for views. For all views: - - if not logged in, redirect to login page - - if logged in as admin, can access page - """ - utils.test_logged_out_redirect_to_login(self, url) - utils.test_user_can_access(self, self.admin_user, url) # admin can access - - -class AllocationListViewTest(AllocationViewBaseTest): - """Tests for AllocationListView""" - - @classmethod - def setUpTestData(cls): - """Set up users and project for testing""" - super(AllocationListViewTest, cls).setUpTestData() - cls.additional_allocations = [AllocationFactory() for i in list(range(100))] - for allocation in cls.additional_allocations: - allocation.resources.add(ResourceFactory(name='holylfs09/tier1')) - cls.nonproj_nonallocation_user = UserFactory() - - def test_allocation_list_access_admin(self): - """Confirm that AllocationList access control works for admin""" - self.allocation_access_tstbase('/allocation/') - # confirm that show_all_allocations=on enables admin to view all allocations - response = self.client.get("/allocation/?show_all_allocations=on") - self.assertEqual(len(response.context['allocation_list']), 25) - - def test_allocation_list_access_pi(self): - """Confirm that AllocationList access control works for pi - When show_all_allocations=on, pi still sees only allocations belonging - to the projects they are pi for. - """ - # confirm that show_all_allocations=on enables admin to view all allocations - self.client.force_login(self.pi_user, backend=BACKEND) - response = self.client.get("/allocation/?show_all_allocations=on") - self.assertEqual(len(response.context['allocation_list']), 1) - - def test_allocation_list_access_user(self): - """Confirm that AllocationList access control works for non-pi users - When show_all_allocations=on, users see only the allocations they - are AllocationUsers of. - """ - # confirm that show_all_allocations=on is accessible to non-admin but - # contains only the user's allocations - self.client.force_login(self.allocation_user, backend=BACKEND) - response = self.client.get("/allocation/") - self.assertEqual(len(response.context['allocation_list']), 1) - response = self.client.get("/allocation/?show_all_allocations=on") - self.assertEqual(len(response.context['allocation_list']), 1) - # nonallocation user belonging to project can't see allocation - self.client.force_login(self.nonproj_nonallocation_user, backend=BACKEND) - response = self.client.get("/allocation/?show_all_allocations=on") - self.assertEqual(len(response.context['allocation_list']), 0) - # nonallocation user belonging to project can't see allocation - self.client.force_login(self.proj_nonallocation_user, backend=BACKEND) - response = self.client.get("/allocation/?show_all_allocations=on") - self.assertEqual(len(response.context['allocation_list']), 0) - - def test_allocation_list_search_admin(self): - """Confirm that AllocationList search works for admin""" - self.client.force_login(self.admin_user, backend=BACKEND) - base_url = '/allocation/?show_all_allocations=on' - response = self.client.get( - base_url + f'&resource_name={self.allocation.resources.first().pk}' - ) - self.assertEqual(len(response.context['allocation_list']), 1) - - -class AllocationChangeDetailViewTest(AllocationViewBaseTest): - """Tests for AllocationChangeDetailView""" - - def setUp(self): - """create an AllocationChangeRequest to test""" - self.client.force_login(self.admin_user, backend=BACKEND) - AllocationChangeRequestFactory(id=2, allocation=self.allocation) - - def test_allocationchangedetailview_access(self): - response = self.client.get( - reverse('allocation-change-detail', kwargs={'pk': 2}) - ) - self.assertEqual(response.status_code, 200) - - def test_allocationchangedetailview_post_deny(self): - """Test that posting to AllocationChangeDetailView with action=deny - changes the status of the AllocationChangeRequest to denied.""" - param = {'action': 'deny'} - response = self.client.post( - reverse('allocation-change-detail', kwargs={'pk': 2}), param, follow=True - ) - self.assertEqual(response.status_code, 200) - alloc_change_req = AllocationChangeRequest.objects.get(pk=2) - denied_status_id = AllocationChangeStatusChoice.objects.get(name='Denied').pk - self.assertEqual(alloc_change_req.status_id, denied_status_id) - - -class AllocationChangeViewTest(AllocationViewBaseTest): - """Tests for AllocationChangeView""" - - def setUp(self): - self.client.force_login(self.admin_user, backend=BACKEND) - self.post_data = { - 'justification': 'just a test', - 'attributeform-0-new_value': '', - 'attributeform-INITIAL_FORMS': '1', - 'attributeform-MAX_NUM_FORMS': '1', - 'attributeform-MIN_NUM_FORMS': '0', - 'attributeform-TOTAL_FORMS': '1', - 'end_date_extension': 0, - } - self.url = '/allocation/1/change-request' - - def test_allocationchangeview_access(self): - """Test get request""" - self.allocation_access_tstbase(self.url) - utils.test_user_can_access(self, self.pi_user, self.url) # Manager can access - utils.test_user_cannot_access(self, self.allocation_user, self.url) # user can't access - - def test_allocationchangeview_post_extension(self): - """Test post request to extend end date""" - - self.post_data['end_date_extension'] = 90 - self.assertEqual(len(AllocationChangeRequest.objects.all()), 0) - response = self.client.post( - '/allocation/1/change-request', data=self.post_data, follow=True - ) - self.assertEqual(response.status_code, 200) - self.assertContains( - response, "Allocation change request successfully submitted." - ) - self.assertEqual(len(AllocationChangeRequest.objects.all()), 1) - - def test_allocationchangeview_post_no_change(self): - """Post request with no change should not go through""" - - self.assertEqual(len(AllocationChangeRequest.objects.all()), 0) - - response = self.client.post( - '/allocation/1/change-request', data=self.post_data, follow=True - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "You must request a change") - self.assertEqual(len(AllocationChangeRequest.objects.all()), 0) - - -class AllocationDetailViewTest(AllocationViewBaseTest): - """Tests for AllocationDetailView""" - - def setUp(self): - self.url = f'/allocation/{self.allocation.pk}/' - - def test_allocation_detail_access(self): - self.allocation_access_tstbase(self.url) - utils.test_user_can_access(self, self.pi_user, self.url) # PI can access - utils.test_user_cannot_access(self, self.proj_nonallocation_user, self.url) - # check access for allocation user with "Removed" status - - def test_allocationdetail_requestchange_button(self): - """Test visibility of "Request Change" button for different user types""" - utils.page_contains_for_user(self, self.admin_user, self.url, 'Request Change') - utils.page_contains_for_user(self, self.pi_user, self.url, 'Request Change') - utils.page_does_not_contain_for_user( - self, self.allocation_user, self.url, 'Request Change' - ) - - def test_allocationattribute_button_visibility(self): - """Test visibility of "Add Attribute" button for different user types""" - # admin - utils.page_contains_for_user( - self, self.admin_user, self.url, 'Add Allocation Attribute' - ) - utils.page_contains_for_user( - self, self.admin_user, self.url, 'Delete Allocation Attribute' - ) - # pi - utils.page_does_not_contain_for_user( - self, self.pi_user, self.url, 'Add Allocation Attribute' - ) - utils.page_does_not_contain_for_user( - self, self.pi_user, self.url, 'Delete Allocation Attribute' - ) - # allocation user - utils.page_does_not_contain_for_user( - self, self.allocation_user, self.url, 'Add Allocation Attribute' - ) - utils.page_does_not_contain_for_user( - self, self.allocation_user, self.url, 'Delete Allocation Attribute' - ) - - def test_allocationuser_button_visibility(self): - """Test visibility of "Add/Remove Users" buttons for different user types""" - # admin - utils.page_contains_for_user(self, self.admin_user, self.url, 'Add Users') - utils.page_contains_for_user(self, self.admin_user, self.url, 'Remove Users') - # pi - utils.page_contains_for_user(self, self.pi_user, self.url, 'Add Users') - utils.page_contains_for_user(self, self.pi_user, self.url, 'Remove Users') - # allocation user - utils.page_does_not_contain_for_user( - self, self.allocation_user, self.url, 'Add Users' - ) - utils.page_does_not_contain_for_user( - self, self.allocation_user, self.url, 'Remove Users' - ) - - -class AllocationCreateViewTest(AllocationViewBaseTest): - """Tests for the AllocationCreateView""" - - def setUp(self): - self.url = f'/allocation/project/{self.project.pk}/create' # url for AllocationCreateView - self.client.force_login(self.pi_user) - self.post_data = { - 'justification': 'test justification', - 'quantity': '1', - 'resource': f'{self.allocation.resources.first().pk}', - } - - def test_allocationcreateview_access(self): - """Test access to the AllocationCreateView""" - self.allocation_access_tstbase(self.url) - utils.test_user_can_access(self, self.pi_user, self.url) - utils.test_user_cannot_access(self, self.proj_nonallocation_user, self.url) - - def test_allocationcreateview_post(self): - """Test POST to the AllocationCreateView""" - self.assertEqual(len(self.project.allocation_set.all()), 1) - response = self.client.post(self.url, data=self.post_data, follow=True) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Allocation requested.") - self.assertEqual(len(self.project.allocation_set.all()), 2) - - def test_allocationcreateview_post_zeroquantity(self): - """Test POST to the AllocationCreateView""" - self.post_data['quantity'] = '0' - self.assertEqual(len(self.project.allocation_set.all()), 1) - response = self.client.post(self.url, data=self.post_data, follow=True) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Allocation requested.") - self.assertEqual(len(self.project.allocation_set.all()), 2) - - -class AllocationAddUsersViewTest(AllocationViewBaseTest): - """Tests for the AllocationAddUsersView""" - - def setUp(self): - self.url = f'/allocation/{self.allocation.pk}/add-users' - - def test_allocationaddusersview_access(self): - """Test access to AllocationAddUsersView""" - self.allocation_access_tstbase(self.url) - no_permission = 'You do not have permission to add users to the allocation.' - - self.client.force_login(self.admin_user, backend=BACKEND) - admin_response = self.client.get(self.url) - self.assertTrue(no_permission not in str(admin_response.content)) - - self.client.force_login(self.pi_user, backend=BACKEND) - pi_response = self.client.get(self.url) - self.assertTrue(no_permission not in str(pi_response.content)) - - self.client.force_login(self.allocation_user, backend=BACKEND) - user_response = self.client.get(self.url) - self.assertTrue(no_permission in str(user_response.content)) - - -class AllocationRemoveUsersViewTest(AllocationViewBaseTest): - """Tests for the AllocationRemoveUsersView""" - - def setUp(self): - self.url = f'/allocation/{self.allocation.pk}/remove-users' - - def test_allocationremoveusersview_access(self): - self.allocation_access_tstbase(self.url) - - -class AllocationChangeListViewTest(AllocationViewBaseTest): - """Tests for the AllocationChangeListView""" - - def setUp(self): - self.url = '/allocation/change-list' - - def test_allocationchangelistview_access(self): - self.allocation_access_tstbase(self.url) - - -class AllocationNoteCreateViewTest(AllocationViewBaseTest): - """Tests for the AllocationNoteCreateView""" - - def setUp(self): - self.url = f'/allocation/{self.allocation.pk}/allocationnote/add' - - def test_allocationnotecreateview_access(self): - self.allocation_access_tstbase(self.url) diff --git a/coldfront/core/allocation/tests/__init__.py b/coldfront/core/allocation/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/allocation/tests/test_models.py b/coldfront/core/allocation/tests/test_models.py new file mode 100644 index 0000000000..9446540258 --- /dev/null +++ b/coldfront/core/allocation/tests/test_models.py @@ -0,0 +1,406 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Unit tests for the allocation models""" + +import datetime +import sys +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.test import TestCase + +from coldfront.core.allocation.models import ( + Allocation, + AllocationStatusChoice, + AllocationUser, +) +from coldfront.core.project.models import Project +from coldfront.core.test_helpers.factories import ( + AAttributeTypeFactory, + AllocationAttributeFactory, + AllocationAttributeTypeFactory, + AllocationFactory, + AllocationStatusChoiceFactory, + AllocationUserFactory, + AllocationUserStatusChoiceFactory, + ProjectFactory, + ResourceFactory, + UserFactory, +) + + +class AllocationModelTests(TestCase): + """tests for Allocation model""" + + @classmethod + def setUpTestData(cls): + """Set up project to test model properties and methods""" + cls.allocation = AllocationFactory() + cls.allocation.resources.add(ResourceFactory(name="holylfs07/tier1")) + + def test_allocation_str(self): + """test that allocation str method returns correct string""" + allocation_str = "%s (%s)" % (self.allocation.get_parent_resource.name, self.allocation.project.pi) + self.assertEqual(str(self.allocation), allocation_str) + + +class AllocationModelUserMethodTests(TestCase): + """tests for Allocation model add_user, and remove_user methods""" + + @classmethod + def setUpTestData(cls): + """Set up project to test model properties and methods""" + active_ausc = AllocationUserStatusChoiceFactory(name="Active") + removed_ausc = AllocationUserStatusChoiceFactory(name="Removed") + cls.allocation = AllocationFactory(status=AllocationStatusChoiceFactory(name="Active")) + cls.allocation.resources.add(ResourceFactory(name="holylfs07/tier1")) + cls.user = UserFactory() + cls.allocation_user_active = AllocationUserFactory(allocation=cls.allocation, status=active_ausc) + cls.allocation_user_removed = AllocationUserFactory(allocation=cls.allocation, status=removed_ausc) + + @patch("coldfront.core.allocation.signals.allocation_activate_user.send") + def test_active_allocation_add_user(self, mock): + """Test that allocation add_user method activates the given user and sends the allocation_activate_user signal""" + self.allocation.add_user(user=self.user, signal_sender="test") + self.assertEqual(mock.call_args.kwargs.get("sender"), "test") + self.allocation.add_user(user=self.allocation_user_active.user, signal_sender="test") + mock.assert_called_with(sender="test", allocation_user_pk=self.allocation_user_active.pk) + self.allocation.add_user(user=self.allocation_user_removed.user, signal_sender="test") + mock.assert_called_with(sender="test", allocation_user_pk=self.allocation_user_removed.pk) + + self.assertEqual(AllocationUser.objects.get(user__pk=self.user.pk).status.name, "Active") + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_active.pk).status.name, "Active") + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_removed.pk).status.name, "Active") + + @patch("coldfront.core.allocation.signals.allocation_activate_user.send") + def test_inactive_allocation_add_user(self, mock): + """Test that allocation add_user method activates the given user and the allocation_activate_user signal is not sent""" + self.allocation.status = AllocationStatusChoiceFactory(name="Pending") + self.allocation.save() + + self.allocation.add_user(user=self.user, signal_sender="test") + mock.assert_not_called() + self.allocation.add_user(user=self.allocation_user_active.user, signal_sender="test") + mock.assert_not_called() + self.allocation.add_user(user=self.allocation_user_removed.user, signal_sender="test") + mock.assert_not_called() + + self.assertEqual(AllocationUser.objects.get(user__pk=self.user.pk).status.name, "Active") + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_active.pk).status.name, "Active") + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_removed.pk).status.name, "Active") + + @patch("coldfront.core.allocation.signals.allocation_remove_user.send") + def test_remove_user(self, mock): + """Test that allocation remove_user method removes the given user and sends the allocation_remove_user signal""" + self.allocation.remove_user(user=self.user, signal_sender="test") + mock.assert_not_called() + self.allocation.remove_user(user=self.allocation_user_active.user, signal_sender="test") + mock.assert_called_with(sender="test", allocation_user_pk=self.allocation_user_active.pk) + self.allocation.remove_user(user=self.allocation_user_removed.user, signal_sender="test") + mock.assert_called_with(sender="test", allocation_user_pk=self.allocation_user_removed.pk) + + self.assertFalse(AllocationUser.objects.filter(user__pk=self.user.pk).exists()) + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_active.pk).status.name, "Removed") + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_removed.pk).status.name, "Removed") + + +class AllocationModelCleanMethodTests(TestCase): + """tests for Allocation model clean method""" + + @classmethod + def setUpTestData(cls): + """Set up allocation to test clean method""" + cls.active_status: AllocationStatusChoice = AllocationStatusChoiceFactory(name="Active") + cls.expired_status: AllocationStatusChoice = AllocationStatusChoiceFactory(name="Expired") + cls.project: Project = ProjectFactory() + + def test_status_is_expired_and_no_end_date_has_validation_error(self): + """Test that an allocation with status 'expired' and no end date raises a validation error.""" + actual_allocation: Allocation = AllocationFactory.build( + status=self.expired_status, end_date=None, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_expired_and_end_date_not_past_has_validation_error(self): + """Test that an allocation with status 'expired' and end date in the future raises a validation error.""" + end_date_in_the_future: datetime.date = datetime.date.today() + datetime.timedelta(days=1) + actual_allocation: Allocation = AllocationFactory.build( + status=self.expired_status, end_date=end_date_in_the_future, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_expired_and_start_date_after_end_date_has_validation_error(self): + """Test that an allocation with status 'expired' and start date after end date raises a validation error.""" + end_date: datetime.date = datetime.date.today() + datetime.timedelta(days=1) + start_date_after_end_date: datetime.date = end_date + datetime.timedelta(days=1) + + actual_allocation: Allocation = AllocationFactory.build( + status=self.expired_status, start_date=start_date_after_end_date, end_date=end_date, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_expired_and_start_date_before_end_date_no_error(self): + """Test that an allocation with status 'expired' and start date before end date does not raise a validation error.""" + start_date: datetime.date = datetime.datetime(year=2023, month=11, day=2, tzinfo=datetime.timezone.utc).date() + end_date: datetime.date = start_date + datetime.timedelta(days=40) + + actual_allocation: Allocation = AllocationFactory.build( + status=self.expired_status, start_date=start_date, end_date=end_date, project=self.project + ) + actual_allocation.full_clean() + + def test_status_is_expired_and_start_date_equals_end_date_no_error(self): + """Test that an allocation with status 'expired' and start date equal to end date does not raise a validation error.""" + start_and_end_date: datetime.date = datetime.datetime( + year=1997, month=4, day=20, tzinfo=datetime.timezone.utc + ).date() + + actual_allocation: Allocation = AllocationFactory.build( + status=self.expired_status, start_date=start_and_end_date, end_date=start_and_end_date, project=self.project + ) + actual_allocation.full_clean() + + def test_status_is_active_and_no_start_date_has_validation_error(self): + """Test that an allocation with status 'active' and no start date raises a validation error.""" + actual_allocation: Allocation = AllocationFactory.build( + status=self.active_status, start_date=None, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_active_and_no_end_date_has_validation_error(self): + """Test that an allocation with status 'active' and no end date raises a validation error.""" + actual_allocation: Allocation = AllocationFactory.build( + status=self.active_status, end_date=None, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_active_and_start_date_after_end_date_has_validation_error(self): + """Test that an allocation with status 'active' and start date after end date raises a validation error.""" + end_date: datetime.date = datetime.date.today() + datetime.timedelta(days=1) + start_date_after_end_date: datetime.date = end_date + datetime.timedelta(days=1) + + actual_allocation: Allocation = AllocationFactory.build( + status=self.active_status, start_date=start_date_after_end_date, end_date=end_date, project=self.project + ) + with self.assertRaises(ValidationError): + actual_allocation.full_clean() + + def test_status_is_active_and_start_date_before_end_date_no_error(self): + """Test that an allocation with status 'active' and start date before end date does not raise a validation error.""" + start_date: datetime.date = datetime.datetime(year=2001, month=5, day=3, tzinfo=datetime.timezone.utc).date() + end_date: datetime.date = start_date + datetime.timedelta(days=160) + + actual_allocation: Allocation = AllocationFactory.build( + status=self.active_status, start_date=start_date, end_date=end_date, project=self.project + ) + actual_allocation.full_clean() + + def test_status_is_active_and_start_date_equals_end_date_no_error(self): + """Test that an allocation with status 'active' and start date equal to end date does not raise a validation error.""" + start_and_end_date: datetime.date = datetime.datetime( + year=2005, month=6, day=3, tzinfo=datetime.timezone.utc + ).date() + + actual_allocation: Allocation = AllocationFactory.build( + status=self.active_status, start_date=start_and_end_date, end_date=start_and_end_date, project=self.project + ) + actual_allocation.full_clean() + + +class AllocationAttributeModelCleanMethodTests(TestCase): + def _test_clean(self, alloc_attr_type_name: str, alloc_attr_values: list, expect_validation_error: bool): + attribute_type = AAttributeTypeFactory(name=alloc_attr_type_name) + allocation_attribute_type = AllocationAttributeTypeFactory(attribute_type=attribute_type) + allocation_attribute = AllocationAttributeFactory(allocation_attribute_type=allocation_attribute_type) + for value in alloc_attr_values: + with self.subTest(value=value): + if not isinstance(value, str): + raise TypeError("allocation attribute value must be a string") + allocation_attribute.value = value + if expect_validation_error: + with self.assertRaises(ValidationError): + allocation_attribute.clean() + else: + allocation_attribute.clean() + + def test_expect_int_given_int(self): + self._test_clean("Int", ["-1", "0", "1", str(sys.maxsize)], False) + + def test_expect_int_given_float(self): + self._test_clean("Int", ["-1.0", "0.0", "1.0", "2e30"], True) + + def test_expect_int_given_garbage(self): + self._test_clean("Int", ["foobar", "", " ", "\0", "1j"], True) + + def test_expect_float_given_int(self): + self._test_clean("Float", ["-1", "0", "1", str(sys.maxsize)], False) + + def test_expect_float_given_float(self): + self._test_clean("Float", ["-1.0", "0.0", "1.0", "2e30"], False) + + def test_expect_float_given_garbage(self): + self._test_clean("Float", ["foobar", "", " ", "\0", "1j"], True) + + def test_expect_yes_no_given_yes_no(self): + self._test_clean("Yes/No", ["Yes", "No"], False) + + def test_expect_yes_no_given_garbage(self): + self._test_clean("Yes/No", ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j", "yes", "no", "YES", "NO"], True) + + def test_expect_date_given_date(self): + self._test_clean("Date", ["1970-01-01"], False) + + def test_expect_date_given_garbage(self): + self._test_clean("Date", ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j"], True) + + +class AllocationModelStrTests(TestCase): + """Tests for Allocation.__str__""" + + def setUp(self): + self.allocation = AllocationFactory() + self.resource = ResourceFactory() + self.allocation.resources.add(self.resource) + + def test_allocation_str_only_contains_parent_resource_and_project_pi(self): + """Test that the allocation's str only contains self.allocation.get_parent_resource.name and self.allocation.project.pi""" + parent_resource_name: str = self.allocation.get_parent_resource.name + project_pi: str = self.allocation.project.pi + expected: str = f"{parent_resource_name} ({project_pi})" + actual = str(self.allocation) + self.assertEqual(actual, expected) + + def test_parent_resource_name_updated_changes_str(self): + """Test that when the name of the parent resource changes the str changes""" + project_pi: str = self.allocation.project.pi + + new_name: str = "This is the new name" + self.resource.name = new_name + self.resource.save() + + expected: str = f"{new_name} ({project_pi})" + actual = str(self.allocation) + self.assertEqual(actual, expected) + + def test_project_pi_name_updated_changes_str(self): + """Test that if the name of the PI is updated that the str changes""" + pi: User = self.allocation.project.pi + new_username: str = "This is a new username!" + pi.username = new_username + pi.save() + + parent_resource_name: str = self.allocation.get_parent_resource.name + expected: str = f"{parent_resource_name} ({pi})" + actual = str(self.allocation) + self.assertEqual(actual, expected) + + def test_parent_resource_changed_changes_str(self): + """When the original parent resource is removed and replaced with another the str changes""" + original_pi: User = self.allocation.project.pi + + original_string = str(self.allocation) + + self.allocation.resources.clear() + new_resource = ResourceFactory() + self.allocation.resources.add(new_resource) + new_string = str(self.allocation) + + expected_new_string = f"{new_resource.name} ({original_pi})" + + self.assertNotEqual(original_string, new_string) + self.assertIn(new_string, expected_new_string) + + def test_project_changed_changes_str(self): + """When the project associated with this allocation changes the str should change""" + original_string = str(self.allocation) + + new_project = ProjectFactory() + self.allocation.project = new_project + self.allocation.save() + + new_string = str(self.allocation) + expected_new_string = f"{self.resource.name} ({new_project.pi})" + + self.assertNotEqual(original_string, new_string) + self.assertEqual(new_string, expected_new_string) + + def test_project_pi_changed_changes_str(self): + """When the project associated with this allocation has its PI change the str should change""" + original_string = str(self.allocation) + + new_pi = UserFactory() + self.allocation.project.pi = new_pi + self.allocation.save() + + new_string = str(self.allocation) + expected_new_string = f"{self.resource.name} ({new_pi})" + + self.assertNotEqual(original_string, new_string) + self.assertEqual(new_string, expected_new_string) + + +class AllocationModelExpiresInTests(TestCase): + mocked_today = datetime.date(2025, 1, 1) + three_years_after_mocked_today = datetime.date(2028, 1, 1) + four_years_after_mocked_today = datetime.date(2029, 1, 1) + + def test_end_date_is_today_returns_zero(self): + """Test that the expires_in method returns 0 when the end date is today.""" + allocation: Allocation = AllocationFactory(end_date=datetime.date.today()) + self.assertEqual(allocation.expires_in, 0) + + def test_end_date_tomorrow_returns_one(self): + """Test that the expires_in method returns 1 when the end date is tomorrow.""" + tomorrow: datetime.date = datetime.date.today() + datetime.timedelta(days=1) + allocation: Allocation = AllocationFactory(end_date=tomorrow) + self.assertEqual(allocation.expires_in, 1) + + def test_end_date_yesterday_returns_negative_one(self): + """Test that the expires_in method returns -1 when the end date is yesterday.""" + yesterday: datetime.date = datetime.date.today() - datetime.timedelta(days=1) + allocation: Allocation = AllocationFactory(end_date=yesterday) + self.assertEqual(allocation.expires_in, -1) + + def test_end_date_one_week_ago_returns_negative_seven(self): + """Test that the expires_in method returns -7 when the end date is one week ago.""" + days_in_a_week: int = 7 + one_week_ago: datetime.date = datetime.date.today() - datetime.timedelta(days=days_in_a_week) + allocation: Allocation = AllocationFactory(end_date=one_week_ago) + self.assertEqual(allocation.expires_in, -days_in_a_week) + + def test_end_date_in_one_week_returns_seven(self): + """Test that the expires_in method returns 7 when the end date is in one week.""" + days_in_a_week: int = 7 + one_week_from_now: datetime.date = datetime.date.today() + datetime.timedelta(days=days_in_a_week) + allocation: Allocation = AllocationFactory(end_date=one_week_from_now) + self.assertEqual(allocation.expires_in, days_in_a_week) + + def test_end_date_in_three_years_without_leap_day_returns_days_including_no_leap_day(self): + """Test that the expires_in method returns the correct number of days in three years when those years did not have a leap year.""" + days_in_three_years_excluding_leap_year = 365 * 3 + + with patch("coldfront.core.allocation.models.datetime") as mock_datetime: + mock_datetime.date.today.return_value = self.mocked_today + + allocation: Allocation = AllocationFactory(end_date=self.three_years_after_mocked_today) + + self.assertEqual(allocation.expires_in, days_in_three_years_excluding_leap_year) + + def test_end_date_in_four_years_returns_days_including_leap_day(self): + """Test that the expires_in method accounts for the extra day of a leap year.""" + days_in_four_years_including_leap_year = (365 * 4) + 1 + + with patch("coldfront.core.allocation.models.datetime") as mock_datetime: + mock_datetime.date.today.return_value = self.mocked_today + + allocation: Allocation = AllocationFactory(end_date=self.four_years_after_mocked_today) + + self.assertEqual(allocation.expires_in, days_in_four_years_including_leap_year) diff --git a/coldfront/core/allocation/tests/test_views.py b/coldfront/core/allocation/tests/test_views.py new file mode 100644 index 0000000000..fa03f4d337 --- /dev/null +++ b/coldfront/core/allocation/tests/test_views.py @@ -0,0 +1,620 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging +from datetime import date +from http import HTTPStatus + +from dateutil.relativedelta import relativedelta +from django.conf import settings +from django.contrib.auth.models import User +from django.test import TestCase, override_settings +from django.urls import reverse + +from coldfront.core.allocation.models import ( + Allocation, + AllocationAttribute, + AllocationAttributeChangeRequest, + AllocationChangeRequest, +) +from coldfront.core.project.models import ( + Project, + ProjectUser, + ProjectUserRoleChoice, +) +from coldfront.core.test_helpers import utils +from coldfront.core.test_helpers.factories import ( + AAttributeTypeFactory, + AllocationAttributeFactory, + AllocationAttributeTypeFactory, + AllocationChangeRequestFactory, + AllocationFactory, + AllocationStatusChoiceFactory, + AllocationUserFactory, + AllocationUserStatusChoiceFactory, + ProjectFactory, + ProjectStatusChoiceFactory, + ProjectUserFactory, + ProjectUserRoleChoiceFactory, + ResourceFactory, + UserFactory, +) +from coldfront.core.utils.common import import_from_settings + +logging.disable(logging.CRITICAL) + +BACKEND = "django.contrib.auth.backends.ModelBackend" +ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = import_from_settings("ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS") + + +class AllocationViewBaseTest(TestCase): + """Base class for allocation view tests.""" + + @classmethod + def setUpTestData(cls): + """Test Data setup for all allocation view tests.""" + pi_user: User = UserFactory() + pi_user.userprofile.is_pi = True + AllocationStatusChoiceFactory(name="New") + AllocationUserStatusChoiceFactory(name="Removed") + cls.project: Project = ProjectFactory(pi=pi_user, status=ProjectStatusChoiceFactory(name="Active")) + cls.allocation: Allocation = AllocationFactory(project=cls.project, end_date=date.today()) + cls.allocation.resources.add(ResourceFactory(name="holylfs07/tier1")) + # create allocation user that belongs to project + allocation_user = AllocationUserFactory(allocation=cls.allocation) + cls.allocation_user: User = allocation_user.user + ProjectUserFactory(project=cls.project, user=allocation_user.user) + # create project user that isn't an allocationuser + proj_nonallocation_user: ProjectUser = ProjectUserFactory(project=cls.project) + cls.proj_nonallocation_user = proj_nonallocation_user.user + cls.admin_user: User = UserFactory(is_staff=True, is_superuser=True) + manager_role: ProjectUserRoleChoice = ProjectUserRoleChoiceFactory(name="Manager") + ProjectUserFactory(user=pi_user, project=cls.project, role=manager_role) + cls.pi_user = pi_user + # make a quota TB allocation attribute + attr_type = AAttributeTypeFactory(name="Int") + alloc_attr_type = AllocationAttributeTypeFactory( + name="Storage Quota (TB)", attribute_type=attr_type, is_changeable=True + ) + cls.quota_attribute: AllocationAttribute = AllocationAttributeFactory( + allocation=cls.allocation, value=100, allocation_attribute_type=alloc_attr_type + ) + + def allocation_access_tstbase(self, url): + """Test basic access control for views. For all views: + - if not logged in, redirect to login page + - if logged in as admin, can access page + """ + utils.test_logged_out_redirect_to_login(self, url) + utils.test_user_can_access(self, self.admin_user, url) # admin can access + + +class AllocationListViewTest(AllocationViewBaseTest): + """Tests for AllocationListView""" + + @classmethod + def setUpTestData(cls): + """Set up users and project for testing""" + super(AllocationListViewTest, cls).setUpTestData() + cls.additional_allocations = [AllocationFactory() for i in list(range(100))] + for allocation in cls.additional_allocations: + allocation.resources.add(ResourceFactory(name="holylfs09/tier1")) + cls.nonproj_nonallocation_user = UserFactory() + + def test_allocation_list_access_admin(self): + """Confirm that AllocationList access control works for admin""" + self.allocation_access_tstbase("/allocation/") + # confirm that show_all_allocations=on enables admin to view all allocations + response = self.client.get("/allocation/?show_all_allocations=on") + self.assertEqual(len(response.context["allocation_list"]), 25) + + def test_allocation_list_access_pi(self): + """Confirm that AllocationList access control works for pi + When show_all_allocations=on, pi still sees only allocations belonging + to the projects they are pi for. + """ + # confirm that show_all_allocations=on enables admin to view all allocations + self.client.force_login(self.pi_user, backend=BACKEND) + response = self.client.get("/allocation/?show_all_allocations=on") + self.assertEqual(len(response.context["allocation_list"]), 1) + + def test_allocation_list_access_user(self): + """Confirm that AllocationList access control works for non-pi users + When show_all_allocations=on, users see only the allocations they + are AllocationUsers of. + """ + # confirm that show_all_allocations=on is accessible to non-admin but + # contains only the user's allocations + self.client.force_login(self.allocation_user, backend=BACKEND) + response = self.client.get("/allocation/") + self.assertEqual(len(response.context["allocation_list"]), 1) + response = self.client.get("/allocation/?show_all_allocations=on") + self.assertEqual(len(response.context["allocation_list"]), 1) + # nonallocation user belonging to project can't see allocation + self.client.force_login(self.nonproj_nonallocation_user, backend=BACKEND) + response = self.client.get("/allocation/?show_all_allocations=on") + self.assertEqual(len(response.context["allocation_list"]), 0) + # nonallocation user belonging to project can't see allocation + self.client.force_login(self.proj_nonallocation_user, backend=BACKEND) + response = self.client.get("/allocation/?show_all_allocations=on") + self.assertEqual(len(response.context["allocation_list"]), 0) + + def test_allocation_list_search_admin(self): + """Confirm that AllocationList search works for admin""" + self.client.force_login(self.admin_user, backend=BACKEND) + base_url = "/allocation/?show_all_allocations=on" + response = self.client.get(base_url + f"&resource_name={self.allocation.resources.first().pk}") + self.assertEqual(len(response.context["allocation_list"]), 1) + + +class AllocationChangeDetailViewTest(AllocationViewBaseTest): + """Tests for AllocationChangeDetailView""" + + # TODO this view can also be used to modify alloc_change_req.notes + # TODO this view does different things for action=update depending if status is Pending or not + + def setUp(self): + """create an AllocationChangeRequest to test""" + self.client.force_login(self.admin_user, backend=BACKEND) + AllocationChangeRequestFactory(id=2, allocation=self.allocation) # view, deny + AllocationChangeRequestFactory( + id=3, allocation=self.allocation, end_date_extension=ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS[0] + ) # approve end date extension + req4 = AllocationChangeRequestFactory(id=4, allocation=self.allocation) # approve attribute change + AllocationChangeRequestFactory( + id=5, allocation=self.allocation, end_date_extension=ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS[0] + ) # update end date extension + AllocationAttributeChangeRequest.objects.create( + allocation_change_request=req4, allocation_attribute=self.quota_attribute, new_value=200 + ) + + def test_allocationchangedetailview_access_granted(self): + response = self.client.get(reverse("allocation-change-detail", kwargs={"pk": 2})) + utils.assert_response_success(self, response) + + def test_allocationchangedetailview_access_denied(self): + try: + self.client.force_login(self.allocation_user) + response = self.client.get(reverse("allocation-change-detail", kwargs={"pk": 2})) + self.assertEqual(response.status_code, 403) + finally: + self.client.force_login(self.admin_user) + + def test_allocationchangedetailview_post_deny(self): + """Test that posting to AllocationChangeDetailView with action=deny + changes the status of AllocationChangeRequest(pk=2) to Denied.""" + param = {"action": "deny"} + response = self.client.post(reverse("allocation-change-detail", kwargs={"pk": 2}), param, follow=True) + utils.assert_response_success(self, response) + alloc_change_req = AllocationChangeRequest.objects.get(pk=2) + self.assertEqual(alloc_change_req.status.name, "Denied") + + def test_allocationchangedetailview_post_approve_end_date_extension(self): + """Test that posting to AllocationChangeDetailView with action=approve + changes the status of AllocationChangeRequest(pk=3) to Approved and applies the end date extension.""" + alloc_change_req = AllocationChangeRequest.objects.get(pk=3) + self.allocation.refresh_from_db() + alloc_change_req.refresh_from_db() + self.assertEqual(alloc_change_req.status.name, "Pending") + expected_new_end_date = self.allocation.end_date + relativedelta(days=alloc_change_req.end_date_extension) + response = self.client.post( + reverse("allocation-change-detail", kwargs={"pk": 3}), + {"action": "approve", "end_date_extension": alloc_change_req.end_date_extension}, + follow=True, + ) + utils.assert_response_success(self, response) + self.allocation.refresh_from_db() + alloc_change_req.refresh_from_db() + self.assertEqual(alloc_change_req.status.name, "Approved") + self.assertEqual(expected_new_end_date, self.allocation.end_date) + + def test_allocationchangedetailview_post_approve_attribute_change(self): + """Test that posting to AllocationChangeDetailView with action=approve + changes the status of AllocationChangeRequest(pk=4) to Approved and updates the storage quota to 200.""" + alloc_change_req = AllocationChangeRequest.objects.get(pk=4) + self.allocation.refresh_from_db() + alloc_change_req.refresh_from_db() + self.assertEqual(alloc_change_req.status.name, "Pending") + response = self.client.post( + reverse("allocation-change-detail", kwargs={"pk": 4}), + { + "action": "approve", + "attributeform-INITIAL_FORMS": "1", + "attributeform-TOTAL_FORMS": "1", + "attributeform-0-new_value": "200", + }, + follow=True, + ) + utils.assert_response_success(self, response) + self.allocation.refresh_from_db() + alloc_change_req.refresh_from_db() + self.assertEqual(alloc_change_req.status.name, "Approved") + self.assertEqual(200, self.allocation.get_attribute("Storage Quota (TB)")) + + def test_allocationchangedetailview_post_update_end_date_extension(self): + """Test that posting to AllocationChangeDetailView with action=update + does not change the status of AllocationChangeRequest(pk=5) and changes the end date extension.""" + alloc_change_req = AllocationChangeRequest.objects.get(pk=5) + alloc_change_req.refresh_from_db() + self.assertEqual(alloc_change_req.status.name, "Pending") + self.assertEqual(alloc_change_req.end_date_extension, ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS[0]) + response = self.client.post( + reverse("allocation-change-detail", kwargs={"pk": 5}), + {"action": "update", "end_date_extension": ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS[1]}, + follow=True, + ) + utils.assert_response_success(self, response) + alloc_change_req.refresh_from_db() + self.assertEqual(alloc_change_req.status.name, "Pending") + self.assertEqual(alloc_change_req.end_date_extension, ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS[1]) + + +class AllocationChangeViewTest(AllocationViewBaseTest): + """Tests for AllocationChangeView""" + + def setUp(self): + self.client.force_login(self.admin_user, backend=BACKEND) + self.post_data = { + "justification": "just a test", + "attributeform-0-new_value": "", + "attributeform-INITIAL_FORMS": "1", + "attributeform-MAX_NUM_FORMS": "1", + "attributeform-MIN_NUM_FORMS": "0", + "attributeform-TOTAL_FORMS": "1", + "end_date_extension": 0, + } + self.url = f"/allocation/{self.allocation.pk}/change-request" + + def test_allocationchangeview_access(self): + """Test get request""" + self.allocation_access_tstbase(self.url) + utils.test_user_can_access(self, self.pi_user, self.url) # Manager can access + utils.test_user_cannot_access(self, self.allocation_user, self.url) # user can't access + + def test_allocationchangeview_post_attribute_change(self): + """Test post request to change an attribute""" + post_data = self.post_data.copy() + post_data.update({"attributeform-0-pk": self.quota_attribute.pk, "attributeform-0-new_value": "200"}) + self.quota_attribute.refresh_from_db() + self.assertEqual("100", self.quota_attribute.value) + self.assertEqual(len(AllocationAttributeChangeRequest.objects.all()), 0) + response = self.client.post(self.url, data=post_data, follow=True) + utils.assert_response_success(self, response) + self.assertEqual(len(AllocationAttributeChangeRequest.objects.all()), 1) + allocation_attribute_change_request = AllocationAttributeChangeRequest.objects.all()[0] + self.assertEqual( + "Storage Quota (TB)", + allocation_attribute_change_request.allocation_attribute.allocation_attribute_type.name, + ) + self.assertEqual("200", allocation_attribute_change_request.new_value) + + def test_allocationchangeview_post_extension(self): + """Test post request to extend end date""" + + post_data = self.post_data.copy() + post_data["end_date_extension"] = 90 + self.assertEqual(len(AllocationChangeRequest.objects.all()), 0) + response = self.client.post(self.url, data=post_data, follow=True) + utils.assert_response_success(self, response) + self.assertContains(response, "Allocation change request successfully submitted.") + self.assertEqual(len(AllocationChangeRequest.objects.all()), 1) + allocation_change_request = AllocationChangeRequest.objects.all()[0] + self.assertEqual(90, allocation_change_request.end_date_extension) + + def test_allocationchangeview_post_no_change(self): + """Post request with no change should not go through""" + + self.assertEqual(len(AllocationChangeRequest.objects.all()), 0) + + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "You must request a change") + self.assertEqual(len(AllocationChangeRequest.objects.all()), 0) + + +class AllocationAttributeEditViewTest(AllocationViewBaseTest): + """Tests for AllocationAttributeEditView""" + + def setUp(self): + self.client.force_login(self.admin_user, backend=BACKEND) + self.url = f"/allocation/{self.allocation.pk}/allocationattribute/edit" + self.post_data = { + "attributeform-0-value": self.allocation.get_attribute("Storage Quota (TB)"), + "attributeform-INITIAL_FORMS": "1", + "attributeform-MAX_NUM_FORMS": "1", + "attributeform-MIN_NUM_FORMS": "0", + "attributeform-TOTAL_FORMS": "1", + } + + def test_allocationattributeeditview_access(self): + """Test get request""" + self.allocation_access_tstbase(self.url) + utils.test_user_cannot_access(self, self.pi_user, self.url) + utils.test_user_cannot_access(self, self.allocation_user, self.url) + + def test_allocationattributeeditview_post_change_attr(self): + """Test post request to change attribute""" + quota_orig = 100 + quota_new = 200 + + self.assertEqual(self.allocation.get_attribute("Storage Quota (TB)"), quota_orig) + + self.post_data["attributeform-0-value"] = quota_new + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + + self.assertEqual(self.allocation.get_attribute("Storage Quota (TB)"), quota_new) + + def test_allocationattributeeditview_post_no_change(self): + """Test post request with no change""" + quota_orig = 100 + + self.assertEqual(self.allocation.get_attribute("Storage Quota (TB)"), quota_orig) + + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + + self.assertEqual(self.allocation.get_attribute("Storage Quota (TB)"), quota_orig) + + +class AllocationDetailViewTest(AllocationViewBaseTest): + """Tests for AllocationDetailView""" + + def setUp(self): + self.url = f"/allocation/{self.allocation.pk}/" + + def test_allocation_detail_access(self): + self.allocation_access_tstbase(self.url) + utils.test_user_can_access(self, self.pi_user, self.url) # PI can access + utils.test_user_cannot_access(self, self.proj_nonallocation_user, self.url) + # check access for allocation user with "Removed" status + + def test_allocationdetail_requestchange_button(self): + """Test visibility of "Request Change" button for different user types""" + utils.page_contains_for_user(self, self.admin_user, self.url, "Request Change") + utils.page_contains_for_user(self, self.pi_user, self.url, "Request Change") + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Request Change") + + def test_allocationattribute_button_visibility(self): + """Test visibility of "Add Attribute" button for different user types""" + # admin + utils.page_contains_for_user(self, self.admin_user, self.url, "Edit Allocation Attribute") + utils.page_contains_for_user(self, self.admin_user, self.url, "Add Allocation Attribute") + utils.page_contains_for_user(self, self.admin_user, self.url, "Delete Allocation Attribute") + # pi + utils.page_does_not_contain_for_user(self, self.pi_user, self.url, "Edit Allocation Attribute") + utils.page_does_not_contain_for_user(self, self.pi_user, self.url, "Add Allocation Attribute") + utils.page_does_not_contain_for_user(self, self.pi_user, self.url, "Delete Allocation Attribute") + # allocation user + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Edit Allocation Attribute") + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Add Allocation Attribute") + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Delete Allocation Attribute") + + def test_allocationuser_button_visibility(self): + """Test visibility of "Add/Remove Users" buttons for different user types""" + # admin + utils.page_contains_for_user(self, self.admin_user, self.url, "Add Users") + utils.page_contains_for_user(self, self.admin_user, self.url, "Remove Users") + # pi + utils.page_contains_for_user(self, self.pi_user, self.url, "Add Users") + utils.page_contains_for_user(self, self.pi_user, self.url, "Remove Users") + # allocation user + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Add Users") + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Remove Users") + + +class AllocationCreateViewTest(AllocationViewBaseTest): + """Tests for the AllocationCreateView""" + + def setUp(self): + self.url = f"/allocation/project/{self.project.pk}/create" # url for AllocationCreateView + self.client.force_login(self.pi_user) + self.post_data = { + "justification": "test justification", + "quantity": 10, + "resource": self.allocation.resources.first().pk, + "project": self.project.pk, + "is_changeable": True, + "users": [self.proj_nonallocation_user.pk], + "allocation_account": [], + } + + def test_allocationcreateview_access(self): + """Test access to the AllocationCreateView""" + self.allocation_access_tstbase(self.url) + utils.test_user_can_access(self, self.pi_user, self.url) + utils.test_user_cannot_access(self, self.proj_nonallocation_user, self.url) + + def test_allocationcreateview_post(self): + """Test POST to the AllocationCreateView""" + self.assertEqual(len(self.project.allocation_set.all()), 1) + response = self.client.post(self.url, data=self.post_data, follow=True) + utils.assert_response_success(self, response) + self.assertContains(response, "Allocation requested.") + self.assertEqual(len(self.project.allocation_set.all()), 2) + new_allocation = self.project.allocation_set.last() + self.assertEqual(len(new_allocation.resources.all()), 1) + self.assertEqual(len(new_allocation.allocationuser_set.all()), 1) + + def test_allocationcreateview_post_zeroquantity(self): + """Test POST to the AllocationCreateView""" + self.post_data["quantity"] = "0" + self.assertEqual(len(self.project.allocation_set.all()), 1) + response = self.client.post(self.url, data=self.post_data, follow=True) + utils.assert_response_success(self, response) + self.assertContains(response, "Allocation requested.") + self.assertEqual(len(self.project.allocation_set.all()), 2) + new_allocation = self.project.allocation_set.last() + self.assertEqual(len(new_allocation.resources.all()), 1) + self.assertEqual(len(new_allocation.allocationuser_set.all()), 1) + + +class AllocationAddUsersViewTest(AllocationViewBaseTest): + """Tests for the AllocationAddUsersView""" + + @classmethod + def setUpTestData(cls): + """Setup POST data""" + super().setUpTestData() + cls.post_data = { + "userform-0-selected": True, + "userform-TOTAL_FORMS": "1", + "userform-INITIAL_FORMS": "1", + "userform-MIN_NUM_FORMS": "0", + "userform-MAX_NUM_FORMS": "1", + "end_date_extension": 0, + } + cls.url = f"/allocation/{cls.allocation.pk}/add-users" + + def test_allocationaddusersview_access(self): + """Test access to AllocationAddUsersView""" + self.allocation_access_tstbase(self.url) + no_permission = "You do not have permission to add users to the allocation." + + self.client.force_login(self.admin_user, backend=BACKEND) + admin_response = self.client.get(self.url) + self.assertTrue(no_permission not in str(admin_response.content)) + + self.client.force_login(self.pi_user, backend=BACKEND) + pi_response = self.client.get(self.url) + self.assertTrue(no_permission not in str(pi_response.content)) + + self.client.force_login(self.allocation_user, backend=BACKEND) + user_response = self.client.get(self.url) + self.assertTrue(no_permission in str(user_response.content)) + + def test_allocationaddusersview_post_user(self): + """Test that posting to AllocationAddUsersView as unpriviliged user fails""" + self.client.force_login(self.allocation_user, backend=BACKEND) + self.assertEqual(len(self.allocation.allocationuser_set.all()), 1) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 403) + + def test_allocationaddusersview_post_pi(self): + """Test that posting to AllocationAddUsersView as a PI works""" + self.client.force_login(self.pi_user, backend=BACKEND) + self.assertEqual(len(self.allocation.allocationuser_set.all()), 1) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Added 1 user to allocation.") + self.assertEqual(len(self.allocation.allocationuser_set.all()), 2) + + def test_allocationaddusersview_post_admin(self): + """Test that posting to AllocationAddUsersView as a superuser works""" + self.client.force_login(self.admin_user, backend=BACKEND) + self.assertEqual(len(self.allocation.allocationuser_set.all()), 1) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Added 1 user to allocation.") + self.assertEqual(len(self.allocation.allocationuser_set.all()), 2) + + +class AllocationRemoveUsersViewTest(AllocationViewBaseTest): + """Tests for the AllocationRemoveUsersView""" + + @classmethod + def setUpTestData(cls): + """Setup POST data""" + super().setUpTestData() + cls.post_data = { + "userform-0-selected": True, + "userform-TOTAL_FORMS": "1", + "userform-INITIAL_FORMS": "1", + "userform-MIN_NUM_FORMS": "0", + "userform-MAX_NUM_FORMS": "1", + "end_date_extension": 0, + } + cls.url = f"/allocation/{cls.allocation.pk}/remove-users" + + def test_allocationremoveusersview_access(self): + """Test access to AllocationRemoveUsersView""" + self.allocation_access_tstbase(self.url) + no_permission = "You do not have permission to remove users from allocation." + + self.client.force_login(self.admin_user, backend=BACKEND) + admin_response = self.client.get(self.url) + self.assertTrue(no_permission not in str(admin_response.content)) + + self.client.force_login(self.pi_user, backend=BACKEND) + pi_response = self.client.get(self.url) + self.assertTrue(no_permission not in str(pi_response.content)) + + self.client.force_login(self.allocation_user, backend=BACKEND) + user_response = self.client.get(self.url) + self.assertTrue(no_permission in str(user_response.content)) + + def test_allocationremoveusersview_post_user(self): + """Test that posting to AllocationRemoveUsersView as unpriviliged user fails""" + self.client.force_login(self.allocation_user, backend=BACKEND) + self.assertEqual(len(self.allocation.allocationuser_set.all()), 1) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 403) + + def test_allocationremoveusersview_post_pi(self): + """Test that posting to AllocationRemoveUsersView as a PI works""" + self.client.force_login(self.pi_user, backend=BACKEND) + self.assertTrue(self.allocation.allocationuser_set.filter(status__name="Active").exists()) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Removed 1 user from allocation.") + self.assertEqual(len(self.allocation.allocationuser_set.filter(status__name="Removed")), 1) + + def test_allocationremoveusersview_post_admin(self): + """Test that posting to AllocationRemoveUsersView as a superuser works""" + self.client.force_login(self.admin_user, backend=BACKEND) + self.assertTrue(self.allocation.allocationuser_set.filter(status__name="Active").exists()) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Removed 1 user from allocation.") + self.assertEqual(len(self.allocation.allocationuser_set.filter(status__name="Removed")), 1) + + +class AllocationChangeListViewTest(AllocationViewBaseTest): + """Tests for the AllocationChangeListView""" + + def setUp(self): + self.url = "/allocation/change-list" + + def test_allocationchangelistview_access(self): + self.allocation_access_tstbase(self.url) + + +class AllocationNoteCreateViewTest(AllocationViewBaseTest): + """Tests for the AllocationNoteCreateView""" + + def setUp(self): + self.url = f"/allocation/{self.allocation.pk}/allocationnote/add" + + def test_allocationnotecreateview_access(self): + self.allocation_access_tstbase(self.url) + + +@override_settings(ALLOCATION_ACCOUNT_ENABLED=True) +class AllocationAccountCreateViewTest(AllocationViewBaseTest): + """Tests for the AllocationAccountCreateView""" + + def setUp(self): + self.url = "/allocation/add-allocation-account/" + + def test_allocationaccountcreateview_access(self): + self.assertTrue(settings.ALLOCATION_ACCOUNT_ENABLED) + self.allocation_access_tstbase(self.url) + utils.test_user_can_access(self, self.pi_user, self.url) + + def test_allocationaccountcreateview_get_form(self): + self.client.force_login(self.pi_user, backend=BACKEND) + response = self.client.get(self.url) + self.assertContains(response, "Add account names that can be associated with allocations") + + def test_allocationaccountcreateview_post_form(self): + self.client.force_login(self.pi_user, backend=BACKEND) + valid_data = {"name": "deptCE1234"} + response = self.client.post(self.url, data=valid_data, follow=True) + self.assertContains(response, "deptCE1234") + + def test_allocationaccountcreateview_post_invalid_form(self): + self.client.force_login(self.pi_user, backend=BACKEND) + invalid_data = {"name": ""} + response = self.client.post(self.url, data=invalid_data, follow=True) + self.assertContains(response, "This field is required.") diff --git a/coldfront/core/allocation/urls.py b/coldfront/core/allocation/urls.py index 9d0f747be6..ac269dd413 100644 --- a/coldfront/core/allocation/urls.py +++ b/coldfront/core/allocation/urls.py @@ -1,47 +1,78 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.allocation.views as allocation_views +from coldfront.config.core import ALLOCATION_EULA_ENABLE urlpatterns = [ - path('', allocation_views.AllocationListView.as_view(), name='allocation-list'), - path('project//create', - allocation_views.AllocationCreateView.as_view(), name='allocation-create'), - path('/', allocation_views.AllocationDetailView.as_view(), - name='allocation-detail'), - path('change-request//', allocation_views.AllocationChangeDetailView.as_view(), - name='allocation-change-detail'), - path('/delete-attribute-change', allocation_views.AllocationChangeDeleteAttributeView.as_view(), - name='allocation-attribute-change-delete'), - path('/add-users', allocation_views.AllocationAddUsersView.as_view(), - name='allocation-add-users'), - path('/remove-users', allocation_views.AllocationRemoveUsersView.as_view(), - name='allocation-remove-users'), - path('request-list', allocation_views.AllocationRequestListView.as_view(), - name='allocation-request-list'), - path('change-list', allocation_views.AllocationChangeListView.as_view(), - name='allocation-change-list'), - path('/renew', allocation_views.AllocationRenewView.as_view(), - name='allocation-renew'), - path('/allocationattribute/add', - allocation_views.AllocationAttributeCreateView.as_view(), name='allocation-attribute-add'), - path('/change-request', - allocation_views.AllocationChangeView.as_view(), name='allocation-change'), - path('/allocationattribute/delete', - allocation_views.AllocationAttributeDeleteView.as_view(), name='allocation-attribute-delete'), - path('/allocationnote/add', - allocation_views.AllocationNoteCreateView.as_view(), name='allocation-note-add'), - path('allocation-invoice-list', allocation_views.AllocationInvoiceListView.as_view(), - name='allocation-invoice-list'), - path('/invoice/', allocation_views.AllocationInvoiceDetailView.as_view(), - name='allocation-invoice-detail'), - path('allocation//add-invoice-note', - allocation_views.AllocationAddInvoiceNoteView.as_view(), name='allocation-add-invoice-note'), - path('allocation-invoice-note//update', - allocation_views.AllocationUpdateInvoiceNoteView.as_view(), name='allocation-update-invoice-note'), - path('allocation//invoice/delete/', - allocation_views.AllocationDeleteInvoiceNoteView.as_view(), name='allocation-delete-invoice-note'), - path('add-allocation-account/', allocation_views.AllocationAccountCreateView.as_view(), - name='add-allocation-account'), - path('allocation-account-list/', allocation_views.AllocationAccountListView.as_view(), - name='allocation-account-list'), + path("", allocation_views.AllocationListView.as_view(), name="allocation-list"), + path("project//create", allocation_views.AllocationCreateView.as_view(), name="allocation-create"), + path("/", allocation_views.AllocationDetailView.as_view(), name="allocation-detail"), + path( + "change-request//", + allocation_views.AllocationChangeDetailView.as_view(), + name="allocation-change-detail", + ), + path( + "/delete-attribute-change", + allocation_views.AllocationChangeDeleteAttributeView.as_view(), + name="allocation-attribute-change-delete", + ), + path("/add-users", allocation_views.AllocationAddUsersView.as_view(), name="allocation-add-users"), + path("/remove-users", allocation_views.AllocationRemoveUsersView.as_view(), name="allocation-remove-users"), + path("request-list", allocation_views.AllocationRequestListView.as_view(), name="allocation-request-list"), + path("change-list", allocation_views.AllocationChangeListView.as_view(), name="allocation-change-list"), + path("/renew", allocation_views.AllocationRenewView.as_view(), name="allocation-renew"), + path( + "/allocationattribute/add", + allocation_views.AllocationAttributeCreateView.as_view(), + name="allocation-attribute-add", + ), + path("/change-request", allocation_views.AllocationChangeView.as_view(), name="allocation-change"), + path( + "/allocationattribute/edit", + allocation_views.AllocationAttributeEditView.as_view(), + name="allocation-attribute-edit", + ), + path( + "/allocationattribute/delete", + allocation_views.AllocationAttributeDeleteView.as_view(), + name="allocation-attribute-delete", + ), + path( + "/allocationnote/add", allocation_views.AllocationNoteCreateView.as_view(), name="allocation-note-add" + ), + path( + "allocation-invoice-list", allocation_views.AllocationInvoiceListView.as_view(), name="allocation-invoice-list" + ), + path("/invoice/", allocation_views.AllocationInvoiceDetailView.as_view(), name="allocation-invoice-detail"), + path( + "allocation//add-invoice-note", + allocation_views.AllocationAddInvoiceNoteView.as_view(), + name="allocation-add-invoice-note", + ), + path( + "allocation-invoice-note//update", + allocation_views.AllocationUpdateInvoiceNoteView.as_view(), + name="allocation-update-invoice-note", + ), + path( + "allocation//invoice/delete/", + allocation_views.AllocationDeleteInvoiceNoteView.as_view(), + name="allocation-delete-invoice-note", + ), + path( + "add-allocation-account/", allocation_views.AllocationAccountCreateView.as_view(), name="add-allocation-account" + ), + path( + "allocation-account-list/", allocation_views.AllocationAccountListView.as_view(), name="allocation-account-list" + ), ] + +if ALLOCATION_EULA_ENABLE: + urlpatterns.append( + path("/review-eula", allocation_views.AllocationEULAView.as_view(), name="allocation-review-eula") + ) diff --git a/coldfront/core/allocation/utils.py b/coldfront/core/allocation/utils.py index b166997b74..d091d0aad4 100644 --- a/coldfront/core/allocation/utils.py +++ b/coldfront/core/allocation/utils.py @@ -1,23 +1,25 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.db.models import Q -from coldfront.core.allocation.models import (AllocationUser, - AllocationUserStatusChoice) +from coldfront.core.allocation.models import AllocationUser, AllocationUserStatusChoice from coldfront.core.resource.models import Resource def set_allocation_user_status_to_error(allocation_user_pk): allocation_user_obj = AllocationUser.objects.get(pk=allocation_user_pk) - error_status = AllocationUserStatusChoice.objects.get(name='Error') + error_status = AllocationUserStatusChoice.objects.get(name="Error") allocation_user_obj.status = error_status allocation_user_obj.save() def generate_guauge_data_from_usage(name, value, usage): - label = "%s: %.2f of %.2f" % (name, usage, value) try: - percent = (usage/value)*100 + percent = (usage / value) * 100 except ZeroDivisionError: percent = 100 except ValueError: @@ -34,28 +36,33 @@ def generate_guauge_data_from_usage(name, value, usage): "columns": [ [label, percent], ], - "type": 'gauge', - "colors": { - label: color - } + "type": "gauge", + "colors": {label: color}, } return usage_data def get_user_resources(user_obj): - if user_obj.is_superuser: resources = Resource.objects.filter(is_allocatable=True) else: resources = Resource.objects.filter( - Q(is_allocatable=True) & - Q(is_available=True) & - (Q(is_public=True) | Q(allowed_groups__in=user_obj.groups.all()) | Q(allowed_users__in=[user_obj,])) + Q(is_allocatable=True) + & Q(is_available=True) + & ( + Q(is_public=True) + | Q(allowed_groups__in=user_obj.groups.all()) + | Q( + allowed_users__in=[ + user_obj, + ] + ) + ) ).distinct() return resources def test_allocation_function(allocation_pk): - print('test_allocation_function', allocation_pk) + print("test_allocation_function", allocation_pk) diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 216c3531ca..d95896b2dc 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -1,409 +1,591 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime import logging from datetime import date -import json from dateutil.relativedelta import relativedelta from django import forms +from django.conf import settings from django.contrib import messages from django.contrib.auth import get_user_model -from django.contrib.auth.models import User from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q from django.db.models.query import QuerySet from django.forms import formset_factory -from django.http import HttpResponseRedirect, JsonResponse, HttpResponseBadRequest -from django.shortcuts import get_object_or_404, render +from django.http import HttpResponseBadRequest, HttpResponseRedirect, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy -from django.utils.html import format_html, mark_safe from django.views import View from django.views.generic import ListView, TemplateView from django.views.generic.edit import CreateView, FormView, UpdateView -from coldfront.core.allocation.forms import (AllocationAccountForm, - AllocationAddUserForm, - AllocationAttributeCreateForm, - AllocationAttributeDeleteForm, - AllocationChangeForm, - AllocationChangeNoteForm, - AllocationAttributeChangeForm, - AllocationAttributeUpdateForm, - AllocationForm, - AllocationInvoiceNoteDeleteForm, - AllocationInvoiceUpdateForm, - AllocationRemoveUserForm, - AllocationReviewUserForm, - AllocationSearchForm, - AllocationUpdateForm) -from coldfront.core.allocation.models import (Allocation, - AllocationPermission, - AllocationAccount, - AllocationAttribute, - AllocationAttributeType, - AllocationChangeRequest, - AllocationChangeStatusChoice, - AllocationAttributeChangeRequest, - AllocationStatusChoice, - AllocationUser, - AllocationUserNote, - AllocationUserStatusChoice) -from coldfront.core.allocation.signals import (allocation_new, - allocation_activate, - allocation_activate_user, - allocation_disable, - allocation_remove_user, - allocation_change_approved,) -from coldfront.core.allocation.utils import (generate_guauge_data_from_usage, - get_user_resources) -from coldfront.core.project.models import (Project, ProjectUser, ProjectPermission, - ProjectUserStatusChoice) +from coldfront.config.core import ALLOCATION_EULA_ENABLE +from coldfront.core.allocation.forms import ( + AllocationAccountForm, + AllocationAddUserForm, + AllocationAttributeChangeForm, + AllocationAttributeCreateForm, + AllocationAttributeDeleteForm, + AllocationAttributeEditForm, + AllocationAttributeUpdateForm, + AllocationChangeForm, + AllocationChangeNoteForm, + AllocationForm, + AllocationInvoiceNoteDeleteForm, + AllocationInvoiceUpdateForm, + AllocationRemoveUserForm, + AllocationReviewUserForm, + AllocationSearchForm, + AllocationUpdateForm, +) +from coldfront.core.allocation.models import ( + Allocation, + AllocationAccount, + AllocationAttribute, + AllocationAttributeChangeRequest, + AllocationAttributeType, + AllocationChangeRequest, + AllocationChangeStatusChoice, + AllocationPermission, + AllocationStatusChoice, + AllocationUser, + AllocationUserNote, + AllocationUserStatusChoice, +) +from coldfront.core.allocation.signals import ( + allocation_activate, + allocation_activate_user, + allocation_attribute_changed, + allocation_change_approved, + allocation_change_created, + allocation_disable, + allocation_new, + allocation_remove_user, +) +from coldfront.core.allocation.utils import generate_guauge_data_from_usage, get_user_resources +from coldfront.core.project.models import Project, ProjectPermission from coldfront.core.resource.models import Resource from coldfront.core.utils.common import get_domain_url, import_from_settings -from coldfront.core.utils.mail import send_allocation_admin_email, send_allocation_customer_email - -ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings( - 'ALLOCATION_ENABLE_ALLOCATION_RENEWAL', True) -ALLOCATION_DEFAULT_ALLOCATION_LENGTH = import_from_settings( - 'ALLOCATION_DEFAULT_ALLOCATION_LENGTH', 365) +from coldfront.core.utils.mail import ( + send_allocation_admin_email, + send_allocation_customer_email, + send_allocation_eula_customer_email, + send_email_template, +) + +ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings("ALLOCATION_ENABLE_ALLOCATION_RENEWAL", True) +ALLOCATION_DEFAULT_ALLOCATION_LENGTH = import_from_settings("ALLOCATION_DEFAULT_ALLOCATION_LENGTH", 365) ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT = import_from_settings( - 'ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT', True) + "ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT", True +) -PROJECT_ENABLE_PROJECT_REVIEW = import_from_settings( - 'PROJECT_ENABLE_PROJECT_REVIEW', False) -INVOICE_ENABLED = import_from_settings('INVOICE_ENABLED', False) +PROJECT_ENABLE_PROJECT_REVIEW = import_from_settings("PROJECT_ENABLE_PROJECT_REVIEW", False) +INVOICE_ENABLED = import_from_settings("INVOICE_ENABLED", False) if INVOICE_ENABLED: - INVOICE_DEFAULT_STATUS = import_from_settings( - 'INVOICE_DEFAULT_STATUS', 'Pending Payment') + INVOICE_DEFAULT_STATUS = import_from_settings("INVOICE_DEFAULT_STATUS", "Pending Payment") -ALLOCATION_ACCOUNT_ENABLED = import_from_settings( - 'ALLOCATION_ACCOUNT_ENABLED', False) -ALLOCATION_ACCOUNT_MAPPING = import_from_settings( - 'ALLOCATION_ACCOUNT_MAPPING', {}) +ALLOCATION_ACCOUNT_ENABLED = import_from_settings("ALLOCATION_ACCOUNT_ENABLED", False) +ALLOCATION_ACCOUNT_MAPPING = import_from_settings("ALLOCATION_ACCOUNT_MAPPING", {}) +EMAIL_SENDER = import_from_settings("EMAIL_SENDER") +EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = import_from_settings("EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT", False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS = import_from_settings("EMAIL_ALLOCATION_EULA_CONFIRMATIONS", False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS = import_from_settings( + "EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS", False +) +EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA = import_from_settings("EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA", False) logger = logging.getLogger(__name__) + class AllocationDetailView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = Allocation - template_name = 'allocation/allocation_detail.html' - context_object_name = 'allocation' + template_name = "allocation/allocation_detail.html" + context_object_name = "allocation" def test_func(self): - """ UserPassesTestMixin Tests""" - pk = self.kwargs.get('pk') + """UserPassesTestMixin Tests""" + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - if self.request.user.has_perm('allocation.can_view_all_allocations'): + if self.request.user.has_perm("allocation.can_view_all_allocations"): return True return allocation_obj.has_perm(self.request.user, AllocationPermission.USER) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') - allocation_obj = get_object_or_404(Allocation, pk=pk) - allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed']).order_by('user__username') + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation.objects.select_related("status", "project"), pk=pk) + allocation_users = ( + allocation_obj.allocationuser_set.select_related("user", "status") + .exclude( + status__name__in=[ + "Removed", + ] + ) + .order_by("user__username") + ) + + if ALLOCATION_EULA_ENABLE: + user_in_allocation = allocation_users.filter(user=self.request.user).exists() + context["user_in_allocation"] = user_in_allocation + + if user_in_allocation: + allocation_user_status = get_object_or_404( + AllocationUser, allocation=allocation_obj, user=self.request.user + ).status + if allocation_obj.status.name == "Active" and allocation_user_status.name == "PendingEula": + messages.info(self.request, "This allocation is active, but you must agree to the EULA to use it!") + + context["eulas"] = allocation_obj.get_eula() + context["res"] = allocation_obj.get_parent_resource.pk + context["res_obj"] = allocation_obj.get_parent_resource # set visible usage attributes alloc_attr_set = allocation_obj.get_attribute_set(self.request.user) - attributes_with_usage = [a for a in alloc_attr_set if hasattr(a, 'allocationattributeusage')] + alloc_attr_set = alloc_attr_set.select_related("allocation_attribute_type", "allocationattributeusage") + attributes_with_usage = [a for a in alloc_attr_set if hasattr(a, "allocationattributeusage")] attributes = alloc_attr_set - allocation_changes = allocation_obj.allocationchangerequest_set.all().order_by('-pk') + allocation_changes = allocation_obj.allocationchangerequest_set.select_related("status").all().order_by("-pk") - guage_data = [] invalid_attributes = [] for attribute in attributes_with_usage: try: - guage_data.append(generate_guauge_data_from_usage( - attribute.allocation_attribute_type.name, - float(attribute.value), - float(attribute.allocationattributeusage.value) - )) + float(attribute.value) + float(attribute.allocationattributeusage.value) except ValueError: - logger.error("Allocation attribute '%s' is not an int but has a usage", - attribute.allocation_attribute_type.name) + logger.error( + "Allocation attribute '%s' is not an int but has a usage", attribute.allocation_attribute_type.name + ) invalid_attributes.append(attribute) for a in invalid_attributes: attributes_with_usage.remove(a) - context['allocation_users'] = allocation_users - context['guage_data'] = guage_data - context['attributes_with_usage'] = attributes_with_usage - context['attributes'] = attributes - context['allocation_changes'] = allocation_changes + context["allocation_users"] = allocation_users + context["attributes_with_usage"] = attributes_with_usage + context["attributes"] = attributes + context["allocation_changes"] = allocation_changes + context["display_slurm_help"] = "coldfront.plugins.slurm" in settings.INSTALLED_APPS # Can the user update the project? - context['is_allowed_to_update_project'] = allocation_obj.project.has_perm(self.request.user, ProjectPermission.UPDATE) - - noteset = allocation_obj.allocationusernote_set + context["is_allowed_to_update_project"] = allocation_obj.project.has_perm( + self.request.user, ProjectPermission.UPDATE + ) + # Can the user edit allocation change requests? + # condition was taken from core.allocation.views.AllocationChangeDetailView; + # maybe better to make a static method that test_func() in that class will call? + context["can_edit_allocation_changes"] = self.request.user.has_perm( + "allocation.can_view_all_allocations" + ) or allocation_obj.has_perm(self.request.user, AllocationPermission.MANAGER) + + noteset = allocation_obj.allocationusernote_set.select_related("author") notes = noteset.all() if self.request.user.is_superuser else noteset.filter(is_private=False) - context['notes'] = notes - context['ALLOCATION_ENABLE_ALLOCATION_RENEWAL'] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL + context["notes"] = notes return context def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - allocation_obj = get_object_or_404(Allocation, pk=pk) + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation.objects.select_related("status"), pk=pk) initial_data = { - 'status': allocation_obj.status, - 'end_date': allocation_obj.end_date, - 'start_date': allocation_obj.start_date, - 'description': allocation_obj.description, - 'is_locked': allocation_obj.is_locked, - 'is_changeable': allocation_obj.is_changeable, + "status": allocation_obj.status, + "end_date": allocation_obj.end_date, + "start_date": allocation_obj.start_date, + "description": allocation_obj.description, + "is_locked": allocation_obj.is_locked, + "is_changeable": allocation_obj.is_changeable, } form = AllocationUpdateForm(initial=initial_data) if not self.request.user.is_superuser: - form.fields['is_locked'].disabled = True - form.fields['is_changeable'].disabled = True + form.fields["is_locked"].disabled = True + form.fields["is_changeable"].disabled = True context = self.get_context_data() - context['form'] = form - context['allocation'] = allocation_obj + context["form"] = form + context["allocation"] = allocation_obj return self.render_to_response(context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - allocation_obj = get_object_or_404(Allocation, pk=pk) + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation.objects.select_related("status", "project", "project__pi"), pk=pk) + allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=["Removed"]).order_by( + "user__username" + ) + if not self.request.user.is_superuser: - messages.success( - request, 'You do not have permission to update the allocation') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + messages.success(request, "You do not have permission to update the allocation") + return redirect(allocation_obj) initial_data = { - 'status': allocation_obj.status, - 'end_date': allocation_obj.end_date, - 'start_date': allocation_obj.start_date, - 'description': allocation_obj.description, - 'is_locked': allocation_obj.is_locked, - 'is_changeable': allocation_obj.is_changeable, + "status": allocation_obj.status, + "end_date": allocation_obj.end_date, + "start_date": allocation_obj.start_date, + "description": allocation_obj.description, + "is_locked": allocation_obj.is_locked, + "is_changeable": allocation_obj.is_changeable, } form = AllocationUpdateForm(request.POST, initial=initial_data) if not form.is_valid(): context = self.get_context_data() - context['form'] = form - context['allocation'] = allocation_obj + context["form"] = form + context["allocation"] = allocation_obj return render(request, self.template_name, context) - action = request.POST.get('action') - if action not in ['update', 'approve', 'auto-approve', 'deny']: + action = request.POST.get("action") + if action not in ["update", "approve", "auto-approve", "deny"]: return HttpResponseBadRequest("Invalid request") form_data = form.cleaned_data old_status = allocation_obj.status.name - if action in ['update', 'approve', 'deny']: - allocation_obj.end_date = form_data.get('end_date') - allocation_obj.start_date = form_data.get('start_date') - allocation_obj.description = form_data.get('description') - allocation_obj.is_locked = form_data.get('is_locked') - allocation_obj.is_changeable = form_data.get('is_changeable') - allocation_obj.status = form_data.get('status') + if action in ["update", "approve", "deny"]: + allocation_obj.end_date = form_data.get("end_date") + allocation_obj.start_date = form_data.get("start_date") + allocation_obj.description = form_data.get("description") + allocation_obj.is_locked = form_data.get("is_locked") + allocation_obj.is_changeable = form_data.get("is_changeable") + allocation_obj.status = form_data.get("status") - if 'approve' in action: - allocation_obj.status = AllocationStatusChoice.objects.get(name='Active') - elif action == 'deny': - allocation_obj.status = AllocationStatusChoice.objects.get(name='Denied') + if "approve" in action: + allocation_obj.status = AllocationStatusChoice.objects.get(name="Active") + elif action == "deny": + allocation_obj.status = AllocationStatusChoice.objects.get(name="Denied") - if old_status != 'Active' == allocation_obj.status.name: + if old_status != "Active" == allocation_obj.status.name: if not allocation_obj.start_date: allocation_obj.start_date = datetime.datetime.now() - if 'approve' in action or not allocation_obj.end_date: - allocation_obj.end_date = datetime.datetime.now() + relativedelta(days=ALLOCATION_DEFAULT_ALLOCATION_LENGTH) + if "approve" in action or not allocation_obj.end_date: + allocation_obj.end_date = datetime.datetime.now() + relativedelta( + days=ALLOCATION_DEFAULT_ALLOCATION_LENGTH + ) allocation_obj.save() - allocation_activate.send( - sender=self.__class__, allocation_pk=allocation_obj.pk) - allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=['Removed', 'Error']) + allocation_activate.send(sender=self.__class__, allocation_pk=allocation_obj.pk) + allocation_users = allocation_obj.allocationuser_set.exclude( + status__name__in=["Removed", "Error", "DeclinedEULA", "PendingEULA"] + ) for allocation_user in allocation_users: - allocation_activate_user.send( - sender=self.__class__, allocation_user_pk=allocation_user.pk) + allocation_activate_user.send(sender=self.__class__, allocation_user_pk=allocation_user.pk) - send_allocation_customer_email(allocation_obj, 'Allocation Activated', 'email/allocation_activated.txt', domain_url=get_domain_url(self.request)) - if action != 'auto-approve': - messages.success(request, 'Allocation Activated!') + send_allocation_customer_email( + allocation_obj, + "Allocation Activated", + "email/allocation_activated.txt", + domain_url=get_domain_url(self.request), + ) + if action != "auto-approve": + messages.success(request, "Allocation Activated!") - elif old_status != allocation_obj.status.name in ['Denied', 'New', 'Revoked']: + elif old_status != allocation_obj.status.name in ["Denied", "New", "Revoked"]: allocation_obj.start_date = None allocation_obj.end_date = None allocation_obj.save() - if allocation_obj.status.name == ['Denied', 'Revoked']: - allocation_disable.send( - sender=self.__class__, allocation_pk=allocation_obj.pk) - allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed', 'Error']) + if allocation_obj.status.name in ["Denied", "Revoked"]: + allocation_disable.send(sender=self.__class__, allocation_pk=allocation_obj.pk) + allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=["Removed", "Error"]) for allocation_user in allocation_users: - allocation_remove_user.send( - sender=self.__class__, allocation_user_pk=allocation_user.pk) - if allocation_obj.status.name == 'Denied': - send_allocation_customer_email(allocation_obj, 'Allocation Denied', 'email/allocation_denied.txt', domain_url=get_domain_url(self.request)) - messages.success(request, 'Allocation Denied!') - elif allocation_obj.status.name == 'Revoked': - send_allocation_customer_email(allocation_obj, 'Allocation Revoked', 'email/allocation_revoked.txt', domain_url=get_domain_url(self.request)) - messages.success(request, 'Allocation Revoked!') + allocation_remove_user.send(sender=self.__class__, allocation_user_pk=allocation_user.pk) + if allocation_obj.status.name == "Denied": + send_allocation_customer_email( + allocation_obj, + "Allocation Denied", + "email/allocation_denied.txt", + domain_url=get_domain_url(self.request), + ) + messages.success(request, "Allocation Denied!") + elif allocation_obj.status.name == "Revoked": + send_allocation_customer_email( + allocation_obj, + "Allocation Revoked", + "email/allocation_revoked.txt", + domain_url=get_domain_url(self.request), + ) + messages.success(request, "Allocation Revoked!") else: - messages.success(request, 'Allocation updated!') + messages.success(request, "Allocation updated!") else: - messages.success(request, 'Allocation updated!') + messages.success(request, "Allocation updated!") allocation_obj.save() - - if action == 'auto-approve': - messages.success(request, 'Allocation to {} has been ACTIVATED for {} {} ({})'.format( - allocation_obj.get_parent_resource, - allocation_obj.project.pi.first_name, - allocation_obj.project.pi.last_name, - allocation_obj.project.pi.username) + if action == "auto-approve": + messages.success( + request, + "Allocation to {} has been ACTIVATED for {} {} ({})".format( + allocation_obj.get_parent_resource, + allocation_obj.project.pi.first_name, + allocation_obj.project.pi.last_name, + allocation_obj.project.pi.username, + ), ) - return HttpResponseRedirect(reverse('allocation-request-list')) + return HttpResponseRedirect(reverse("allocation-request-list")) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + return redirect(allocation_obj) -class AllocationListView(LoginRequiredMixin, ListView): +class AllocationEULAView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): + model = Allocation + template_name = "allocation/allocation_review_eula.html" + context_object_name = "allocation-eula" + + def test_func(self): + """UserPassesTestMixin Tests""" + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation, pk=pk) + + if self.request.user.has_perm("allocation.can_view_all_allocations"): + return True + + return allocation_obj.has_perm(self.request.user, AllocationPermission.USER) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_users = allocation_obj.allocationuser_set.exclude( + status__name__in=[ + "Removed", + ] + ).order_by("user__username") + user_in_allocation = allocation_users.filter(user=self.request.user).exists() + + context["allocation"] = allocation_obj.pk + context["eulas"] = allocation_obj.get_eula() + context["res"] = allocation_obj.get_parent_resource.pk + context["res_obj"] = allocation_obj.get_parent_resource + + if user_in_allocation and ALLOCATION_EULA_ENABLE: + allocation_user_status = get_object_or_404( + AllocationUser, allocation=allocation_obj, user=self.request.user + ).status + context["allocation_user_status"] = allocation_user_status.name + context["last_updated"] = get_object_or_404( + AllocationUser, allocation=allocation_obj, user=self.request.user + ).modified + + return context + + def get(self, request, *args, **kwargs): + pk = self.kwargs.get("pk") + get_object_or_404(Allocation, pk=pk) + context = self.get_context_data() + return self.render_to_response(context) + + def post(self, request, *args, **kwargs): + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_users = allocation_obj.allocationuser_set.exclude( + status__name__in=["Removed", "DeclinedEULA"] + ).order_by("user__username") + user_in_allocation = allocation_users.filter(user=self.request.user).exists() + if user_in_allocation: + allocation_user_obj = get_object_or_404(AllocationUser, allocation=allocation_obj, user=self.request.user) + action = request.POST.get("action") + if action not in ["accepted_eula", "declined_eula"]: + return HttpResponseBadRequest("Invalid request") + if "accepted_eula" in action: + allocation_user_obj.status = AllocationUserStatusChoice.objects.get(name="Active") + messages.success(self.request, "EULA Accepted!") + if EMAIL_ALLOCATION_EULA_CONFIRMATIONS: + project_user = allocation_user_obj.allocation.project.projectuser_set.get( + user=allocation_user_obj.user + ) + if EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT or project_user.enable_notifications: + send_allocation_eula_customer_email( + allocation_user_obj, + "EULA accepted", + "email/allocation_eula_accepted.txt", + cc_managers=EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS, + include_eula=EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA, + ) + if allocation_obj.status == AllocationStatusChoice.objects.get(name="Active"): + allocation_activate_user.send(sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) + elif action == "declined_eula": + allocation_user_obj.status = AllocationUserStatusChoice.objects.get(name="DeclinedEULA") + messages.warning( + self.request, + "You did not agree to the EULA and were removed from the allocation. To access this allocation, your PI will have to re-add you.", + ) + if EMAIL_ALLOCATION_EULA_CONFIRMATIONS: + project_user = allocation_user_obj.allocation.project.projectuser_set.get( + user=allocation_user_obj.user + ) + if EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT or project_user.enable_notifications: + send_allocation_eula_customer_email( + allocation_user_obj, + "EULA declined", + "email/allocation_eula_declined.txt", + cc_managers=EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS, + ) + allocation_user_obj.save() + + return HttpResponseRedirect(reverse("allocation-review-eula", kwargs={"pk": pk})) + +class AllocationListView(LoginRequiredMixin, ListView): model = Allocation - template_name = 'allocation/allocation_list.html' - context_object_name = 'allocation_list' + template_name = "allocation/allocation_list.html" + context_object_name = "allocation_list" paginate_by = 25 def get_queryset(self): - - order_by = self.request.GET.get('order_by') + order_by = self.request.GET.get("order_by") if order_by: - direction = self.request.GET.get('direction') - dir_dict = {'asc':'', 'des':'-'} + direction = self.request.GET.get("direction") + dir_dict = {"asc": "", "des": "-"} order_by = dir_dict[direction] + order_by else: - order_by = 'id' + order_by = "id" allocation_search_form = AllocationSearchForm(self.request.GET) if allocation_search_form.is_valid(): data = allocation_search_form.cleaned_data - if data.get('show_all_allocations') and (self.request.user.is_superuser or self.request.user.has_perm('allocation.can_view_all_allocations')): - allocations = Allocation.objects.prefetch_related( - 'project', 'project__pi', 'status',).all().order_by(order_by) + if data.get("show_all_allocations") and ( + self.request.user.is_superuser or self.request.user.has_perm("allocation.can_view_all_allocations") + ): + allocations = ( + Allocation.objects.select_related( + "project", + "project__pi", + "status", + ) + .all() + .order_by(order_by) + ) else: - allocations = Allocation.objects.prefetch_related('project', 'project__pi', 'status',).filter( - Q(project__status__name__in=['New', 'Active', ]) & - Q(project__projectuser__status__name='Active') & - Q(project__projectuser__user=self.request.user) & - - (Q(project__projectuser__role__name='Manager') | - Q(allocationuser__user=self.request.user) & - Q(allocationuser__status__name='Active')) - ).distinct().order_by(order_by) + allocations = ( + Allocation.objects.select_related( + "project", + "project__pi", + "status", + ) + .filter( + Q(project__status__name__in=["New", "Active"]) + & Q(project__projectuser__status__name__in=["Active"]) + & Q(project__projectuser__user=self.request.user) + & ( + Q(project__projectuser__role__name="Manager") + | Q(allocationuser__user=self.request.user) + & Q(allocationuser__status__name__in=["Active", "PendingEULA"]) + ) + ) + .distinct() + .order_by(order_by) + ) # Project Title - if data.get('project'): - allocations = allocations.filter( - project__title__icontains=data.get('project')) + if data.get("project"): + allocations = allocations.filter(project__title__icontains=data.get("project")) # username - if data.get('username'): + if data.get("username"): allocations = allocations.filter( - Q(project__pi__username__icontains=data.get('username')) | - Q(allocationuser__user__username__icontains=data.get('username')) & - Q(allocationuser__status__name='Active') + Q(project__pi__username__icontains=data.get("username")) + | Q(allocationuser__user__username__icontains=data.get("username")) + & Q(allocationuser__status__name__in=["PendingEULA", "Active"]) ) # Resource Type - if data.get('resource_type'): - allocations = allocations.filter( - resources__resource_type=data.get('resource_type')) + if data.get("resource_type"): + allocations = allocations.filter(resources__resource_type=data.get("resource_type")) # Resource Name - if data.get('resource_name'): - allocations = allocations.filter( - resources__in=data.get('resource_name')) + if data.get("resource_name"): + allocations = allocations.filter(resources__in=data.get("resource_name")) # Allocation Attribute Name - if data.get('allocation_attribute_name') and data.get('allocation_attribute_value'): + if data.get("allocation_attribute_name") and data.get("allocation_attribute_value"): allocations = allocations.filter( - Q(allocationattribute__allocation_attribute_type=data.get('allocation_attribute_name')) & - Q(allocationattribute__value=data.get( - 'allocation_attribute_value')) + Q(allocationattribute__allocation_attribute_type=data.get("allocation_attribute_name")) + & Q(allocationattribute__value=data.get("allocation_attribute_value")) ) # End Date - if data.get('end_date'): - allocations = allocations.filter(end_date__lt=data.get( - 'end_date'), status__name='Active').order_by('end_date') + if data.get("end_date"): + allocations = allocations.filter(end_date__lt=data.get("end_date"), status__name="Active").order_by( + "end_date" + ) # Active from now until date - if data.get('active_from_now_until_date'): + if data.get("active_from_now_until_date"): + allocations = allocations.filter(end_date__gte=date.today()) allocations = allocations.filter( - end_date__gte=date.today()) - allocations = allocations.filter(end_date__lt=data.get( - 'active_from_now_until_date'), status__name='Active').order_by('end_date') + end_date__lt=data.get("active_from_now_until_date"), status__name="Active" + ).order_by("end_date") # Status - if data.get('status'): - allocations = allocations.filter( - status__in=data.get('status')) + if data.get("status"): + allocations = allocations.filter(status__in=data.get("status")) else: - allocations = Allocation.objects.prefetch_related('project', 'project__pi', 'status',).filter( - Q(allocationuser__user=self.request.user) & - Q(allocationuser__status__name='Active') - ).order_by(order_by) + allocations = ( + Allocation.objects.select_related( + "project", + "project__pi", + "status", + ) + .filter( + Q(allocationuser__user=self.request.user) + & Q(allocationuser__status__name__in=["PendingEULA", "Active"]) + ) + .order_by(order_by) + ) return allocations.distinct() def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) allocations_count = self.get_queryset().count() - context['allocations_count'] = allocations_count + context["allocations_count"] = allocations_count allocation_search_form = AllocationSearchForm(self.request.GET) if allocation_search_form.is_valid(): data = allocation_search_form.cleaned_data - filter_parameters = '' + filter_parameters = "" for key, value in data.items(): if value: if isinstance(value, QuerySet): - filter_parameters += ''.join([f'{key}={ele.pk}&' for ele in value]) - elif hasattr(value, 'pk'): - filter_parameters += f'{key}={value.pk}&' + filter_parameters += "".join([f"{key}={ele.pk}&" for ele in value]) + elif hasattr(value, "pk"): + filter_parameters += f"{key}={value.pk}&" else: - filter_parameters += f'{key}={value}&' - context['allocation_search_form'] = allocation_search_form + filter_parameters += f"{key}={value}&" + context["allocation_search_form"] = allocation_search_form else: filter_parameters = None - context['allocation_search_form'] = AllocationSearchForm() + context["allocation_search_form"] = AllocationSearchForm() - order_by = self.request.GET.get('order_by') + order_by = self.request.GET.get("order_by") if order_by: - direction = self.request.GET.get('direction') - filter_parameters_with_order_by = filter_parameters + \ - 'order_by=%s&direction=%s&' % (order_by, direction) + direction = self.request.GET.get("direction") + filter_parameters_with_order_by = filter_parameters + "order_by=%s&direction=%s&" % (order_by, direction) else: filter_parameters_with_order_by = filter_parameters if filter_parameters: - context['expand_accordion'] = 'show' - context['filter_parameters'] = filter_parameters - context['filter_parameters_with_order_by'] = filter_parameters_with_order_by + context["expand_accordion"] = "show" + context["filter_parameters"] = filter_parameters + context["filter_parameters_with_order_by"] = filter_parameters_with_order_by - allocation_list = context.get('allocation_list') + allocation_list = context.get("allocation_list") paginator = Paginator(allocation_list, self.paginate_by) - page = self.request.GET.get('page') + page = self.request.GET.get("page") try: allocation_list = paginator.page(page) @@ -415,832 +597,823 @@ def get_context_data(self, **kwargs): return context -class AllocationCreateView(LoginRequiredMixin, UserPassesTestMixin, FormView): +class AllocationCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): form_class = AllocationForm - template_name = 'allocation/allocation_create.html' + template_name = "allocation/allocation_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + """UserPassesTestMixin Tests""" + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.has_perm(self.request.user, ProjectPermission.UPDATE): return True - messages.error(self.request, 'You do not have permission to create a new allocation.') + messages.error(self.request, "You do not have permission to create a new allocation.") return False def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + self.project = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) - if project_obj.needs_review: - messages.error(request, 'You cannot request a new allocation because you have to review your project first.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + if self.project.needs_review: + messages.error( + request, "You cannot request a new allocation because you have to review your project first." + ) + return redirect(self.project) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error(request, 'You cannot request a new allocation to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + if self.project.status.name not in ["Active", "New"]: + messages.error(request, "You cannot request a new allocation to an archived project.") + return redirect(self.project) return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - context['project'] = project_obj + context["project"] = self.project user_resources = get_user_resources(self.request.user) resources_form_default_quantities = {} + resources_form_descriptions = {} resources_form_label_texts = {} resources_with_eula = {} - attr_names = ('quantity_default_value', 'quantity_label', 'eula') + attr_names = ("quantity_default_value", "form_description", "quantity_label", "eula") for resource in user_resources: for attr_name in attr_names: query = Q(resource_attribute_type__name=attr_name) if resource.resourceattribute_set.filter(query).exists(): value = resource.resourceattribute_set.get(query).value - if attr_name == 'quantity_default_value': + if attr_name == "quantity_default_value": resources_form_default_quantities[resource.id] = int(value) - if attr_name == 'quantity_label': - resources_form_label_texts[resource.id] = mark_safe(f'{value}*') - if attr_name == 'eula': + if attr_name == "form_description": + resources_form_descriptions[resource.id] = value + if attr_name == "quantity_label": + resources_form_label_texts[resource.id] = value + if attr_name == "eula": resources_with_eula[resource.id] = value - context['resources_form_default_quantities'] = resources_form_default_quantities - context['resources_form_label_texts'] = resources_form_label_texts - context['resources_with_eula'] = resources_with_eula - context['resources_with_accounts'] = list(Resource.objects.filter( - name__in=list(ALLOCATION_ACCOUNT_MAPPING.keys())).values_list('id', flat=True)) + context["resources_form_default_quantities"] = resources_form_default_quantities + context["resources_form_descriptions"] = resources_form_descriptions + context["resources_form_label_texts"] = resources_form_label_texts + context["resources_with_eula"] = resources_with_eula + context["resources_with_accounts"] = list( + Resource.objects.filter(name__in=list(ALLOCATION_ACCOUNT_MAPPING.keys())).values_list("id", flat=True) + ) return context - def get_form(self, form_class=None): - """Return an instance of the form to be used in this view.""" - if form_class is None: - form_class = self.get_form_class() - return form_class(self.request.user, self.kwargs.get('project_pk'), **self.get_form_kwargs()) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["request_user"] = self.request.user + kwargs["project_pk"] = self.project.pk + return kwargs def form_valid(self, form): + redirect = super().form_valid(form) form_data = form.cleaned_data - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - resource_obj = form_data.get('resource') - justification = form_data.get('justification') - quantity = form_data.get('quantity', 1) - allocation_account = form_data.get('allocation_account', None) - # A resource is selected that requires an account name selection but user has no account names - if ALLOCATION_ACCOUNT_ENABLED and resource_obj.name in ALLOCATION_ACCOUNT_MAPPING and AllocationAttributeType.objects.filter( - name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name]).exists() and not allocation_account: - form.add_error(None, format_html( - 'You need to create an account name. Create it by clicking the link under the "Allocation account" field.')) - return self.form_invalid(form) - - usernames = form_data.get('users') - usernames.append(project_obj.pi.username) - usernames = list(set(usernames)) - - users = [get_user_model().objects.get(username=username) for username in usernames] - if project_obj.pi not in users: - users.append(project_obj.pi) - - if INVOICE_ENABLED and resource_obj.requires_payment: - allocation_status_obj = AllocationStatusChoice.objects.get( - name=INVOICE_DEFAULT_STATUS) - else: - allocation_status_obj = AllocationStatusChoice.objects.get( - name='New') + resource_obj = form_data.get("resource") + allocation_account = form_data.get("allocation_account", None) - allocation_obj = Allocation.objects.create( - project=project_obj, - justification=justification, - quantity=quantity, - status=allocation_status_obj - ) - - if ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT: - allocation_obj.is_changeable = True - allocation_obj.save() + # add users to allocation + self.object.add_user(self.project.pi, signal_sender=self.__class__) + users = form_data.get("users") + for user in users: + self.object.add_user(user, signal_sender=self.__class__) - allocation_obj.resources.add(resource_obj) + # add resources to allocation + self.object.resources.add(resource_obj) + for linked_resource in resource_obj.linked_resources.all(): + self.object.resources.add(linked_resource) + # add allocation account attribute to allocation if ALLOCATION_ACCOUNT_ENABLED and allocation_account and resource_obj.name in ALLOCATION_ACCOUNT_MAPPING: - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name]) - AllocationAttribute.objects.create( + name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name] + ) + self.object.allocationattribute_set.create( allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value=allocation_account + value=allocation_account.name, ) - - for linked_resource in resource_obj.linked_resources.all(): - allocation_obj.resources.add(linked_resource) - - allocation_user_active_status = AllocationUserStatusChoice.objects.get( - name='Active') - for user in users: - AllocationUser.objects.create(allocation=allocation_obj, user=user, - status=allocation_user_active_status) - send_allocation_admin_email( - allocation_obj, - 'New Allocation Request', - 'email/new_allocation_request.txt', - domain_url=get_domain_url(self.request) + self.object, + "New Allocation Request", + "email/new_allocation_request.txt", + domain_url=get_domain_url(self.request), ) - allocation_new.send(sender=self.__class__, - allocation_pk=allocation_obj.pk) - return super().form_valid(form) + allocation_new.send(sender=self.__class__, allocation_pk=self.object.pk) + return redirect def get_success_url(self): - msg = 'Allocation requested. It will be available once it is approved.' - messages.success(self.request, msg) - return reverse('project-detail', kwargs={'pk': self.kwargs.get('project_pk')}) + messages.success(self.request, "Allocation requested. It will be available once it is approved.") + return self.project.get_absolute_url() class AllocationAddUsersView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_add_users.html' + template_name = "allocation/allocation_add_users.html" + model = Allocation + context_object_name = "allocation" def test_func(self): - """ UserPassesTestMixin Tests""" - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if allocation_obj.has_perm(self.request.user, AllocationPermission.MANAGER): return True - messages.error(self.request, 'You do not have permission to add users to the allocation.') + messages.error(self.request, "You do not have permission to add users to the allocation.") return False def dispatch(self, request, *args, **kwargs): - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + allocation_obj = get_object_or_404(Allocation.objects.select_related("status"), pk=self.kwargs.get("pk")) message = None if allocation_obj.is_locked and not self.request.user.is_superuser: - message = 'You cannot modify this allocation because it is locked! Contact support for details.' - elif allocation_obj.status.name not in ['Active', 'New', 'Renewal Requested', 'Payment Pending', 'Payment Requested', 'Paid']: - message = f'You cannot add users to an allocation with status {allocation_obj.status.name}.' + message = "You cannot modify this allocation because it is locked! Contact support for details." + elif allocation_obj.status.name not in [ + "Active", + "New", + "Renewal Requested", + "Payment Pending", + "Payment Requested", + "Paid", + ]: + message = f"You cannot add users to an allocation with status {allocation_obj.status.name}." if message: messages.error(request, message) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + return redirect(allocation_obj) return super().dispatch(request, *args, **kwargs) def get_users_to_add(self, allocation_obj): - active_users_in_project = list(allocation_obj.project.projectuser_set.filter( - status__name='Active').values_list('user__username', flat=True)) - users_already_in_allocation = list(allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed']).values_list('user__username', flat=True)) + active_users_in_project = list( + allocation_obj.project.projectuser_set.filter(status__name="Active").values_list( + "user__username", flat=True + ) + ) + users_already_in_allocation = list( + allocation_obj.allocationuser_set.exclude(status__name__in=["Removed"]).values_list( + "user__username", flat=True + ) + ) - missing_users = list(set(active_users_in_project) - - set(users_already_in_allocation)) - missing_users = get_user_model().objects.filter(username__in=missing_users).exclude( - pk=allocation_obj.project.pi.pk) + missing_users = list(set(active_users_in_project) - set(users_already_in_allocation)) + missing_users = ( + get_user_model().objects.filter(username__in=missing_users).exclude(pk=allocation_obj.project.pi.pk) + ) users_to_add = [ - - {'username': user.username, - 'first_name': user.first_name, - 'last_name': user.last_name, - 'email': user.email, } - + { + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + } for user in missing_users ] return users_to_add def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - allocation_obj = get_object_or_404(Allocation, pk=pk) + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation.objects.select_related("project", "project__pi"), pk=pk) users_to_add = self.get_users_to_add(allocation_obj) context = {} if users_to_add: - formset = formset_factory( - AllocationAddUserForm, max_num=len(users_to_add)) - formset = formset(initial=users_to_add, prefix='userform') - context['formset'] = formset + formset = formset_factory(AllocationAddUserForm, max_num=len(users_to_add)) + formset = formset(initial=users_to_add, prefix="userform") + context["formset"] = formset + + context["allocation"] = allocation_obj + + user_resources = get_user_resources(self.request.user) + resources_with_eula = {} + for res in user_resources: + if res in allocation_obj.get_resources_as_list: + if res.get_attribute_list(name="eula"): + for attr_value in res.get_attribute_list(name="eula"): + resources_with_eula[res] = attr_value + + context["resources_with_eula"] = resources_with_eula + string_accumulator = "" + for res, value in resources_with_eula.items(): + string_accumulator += f"{res}: {value}\n" + context["compiled_eula"] = str(string_accumulator) - context['allocation'] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - allocation_obj = get_object_or_404(Allocation, pk=pk) + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation.objects.select_related("project", "project__pi"), pk=pk) users_to_add = self.get_users_to_add(allocation_obj) - formset = formset_factory( - AllocationAddUserForm, max_num=len(users_to_add)) - formset = formset(request.POST, initial=users_to_add, - prefix='userform') + formset = formset_factory(AllocationAddUserForm, max_num=len(users_to_add)) + formset = formset(request.POST, initial=users_to_add, prefix="userform") users_added_count = 0 - if formset.is_valid(): - - allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get( - name='Active') - - for form in formset: - user_form_data = form.cleaned_data - if user_form_data['selected']: - - users_added_count += 1 - - user_obj = get_user_model().objects.get( - username=user_form_data.get('username')) - - if allocation_obj.allocationuser_set.filter(user=user_obj).exists(): - allocation_user_obj = allocation_obj.allocationuser_set.get( - user=user_obj) - allocation_user_obj.status = allocation_user_active_status_choice - allocation_user_obj.save() - else: - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, user=user_obj, status=allocation_user_active_status_choice) - - allocation_activate_user.send(sender=self.__class__, - allocation_user_pk=allocation_user_obj.pk) - - user_plural = "user" if users_added_count == 1 else "users" - messages.success(request, f'Added {users_added_count} {user_plural} to allocation.') - else: + if not formset.is_valid(): for error in formset.errors: messages.error(request, error) - - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + return redirect + for form in formset: + user_form_data = form.cleaned_data + if user_form_data["selected"]: + users_added_count += 1 + user_obj = get_user_model().objects.get(username=user_form_data.get("username")) + allocation_obj.add_user(user_obj, signal_sender=self.__class__) + if allocation_obj.allocationuser_set.get(user=user_obj).status.name == "Active": + send_email_template( + "You have been added to an allocation", + "email/user_added_to_allocation.txt", + {"user": user_obj, "allocation": allocation_obj}, + [user_obj.email], + ) + + user_plural = "user" if users_added_count == 1 else "users" + messages.success(request, f"Added {users_added_count} {user_plural} to allocation.") + + return redirect(allocation_obj) class AllocationRemoveUsersView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_remove_users.html' + template_name = "allocation/allocation_remove_users.html" def test_func(self): - """ UserPassesTestMixin Tests""" - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if allocation_obj.has_perm(self.request.user, AllocationPermission.MANAGER): return True - messages.error(self.request, 'You do not have permission to remove users from allocation.') + messages.error(self.request, "You do not have permission to remove users from allocation.") return False def dispatch(self, request, *args, **kwargs): - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + allocation_obj = get_object_or_404(Allocation.objects.select_related("status"), pk=self.kwargs.get("pk")) message = None if allocation_obj.is_locked and not self.request.user.is_superuser: - message = 'You cannot modify this allocation because it is locked! Contact support for details.' - elif allocation_obj.status.name not in ['Active', 'New', 'Renewal Requested', ]: - message = f'You cannot remove users from a allocation with status {allocation_obj.status.name}.' + message = "You cannot modify this allocation because it is locked! Contact support for details." + elif allocation_obj.status.name not in [ + "Active", + "New", + "Renewal Requested", + ]: + message = f"You cannot remove users from a allocation with status {allocation_obj.status.name}." if message: messages.error(request, message) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + return redirect(allocation_obj) return super().dispatch(request, *args, **kwargs) def get_users_to_remove(self, allocation_obj): - users_to_remove = list(allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed', 'Error', ]).values_list('user__username', flat=True)) + users_to_remove = list( + allocation_obj.allocationuser_set.exclude( + status__name__in=[ + "Removed", + "Error", + ] + ).values_list("user__username", flat=True) + ) - users_to_remove = get_user_model().objects.filter(username__in=users_to_remove).exclude( - pk__in=[allocation_obj.project.pi.pk, self.request.user.pk]) + users_to_remove = ( + get_user_model() + .objects.filter(username__in=users_to_remove) + .exclude(pk__in=[allocation_obj.project.pi.pk, self.request.user.pk]) + ) users_to_remove = [ - - {'username': user.username, - 'first_name': user.first_name, - 'last_name': user.last_name, - 'email': user.email, } - + { + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + } for user in users_to_remove ] return users_to_remove def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) users_to_remove = self.get_users_to_remove(allocation_obj) context = {} if users_to_remove: - formset = formset_factory( - AllocationRemoveUserForm, max_num=len(users_to_remove)) - formset = formset(initial=users_to_remove, prefix='userform') - context['formset'] = formset + formset = formset_factory(AllocationRemoveUserForm, max_num=len(users_to_remove)) + formset = formset(initial=users_to_remove, prefix="userform") + context["formset"] = formset - context['allocation'] = allocation_obj + context["allocation"] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - allocation_obj = get_object_or_404(Allocation, pk=pk) + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation.objects.select_related("project"), pk=pk) users_to_remove = self.get_users_to_remove(allocation_obj) - formset = formset_factory( - AllocationRemoveUserForm, max_num=len(users_to_remove)) - formset = formset( - request.POST, initial=users_to_remove, prefix='userform') + formset = formset_factory(AllocationRemoveUserForm, max_num=len(users_to_remove)) + formset = formset(request.POST, initial=users_to_remove, prefix="userform") remove_users_count = 0 if formset.is_valid(): - allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get( - name='Removed') for form in formset: user_form_data = form.cleaned_data - if user_form_data['selected']: - + if user_form_data["selected"]: remove_users_count += 1 - user_obj = get_user_model().objects.get( - username=user_form_data.get('username')) + user_obj = get_user_model().objects.get(username=user_form_data.get("username")) if allocation_obj.project.pi == user_obj: continue - allocation_user_obj = allocation_obj.allocationuser_set.get( - user=user_obj) - allocation_user_obj.status = allocation_user_removed_status_choice - allocation_user_obj.save() - allocation_remove_user.send(sender=self.__class__, - allocation_user_pk=allocation_user_obj.pk) + allocation_obj.remove_user(user_obj, signal_sender=self.__class__) user_plural = "user" if remove_users_count == 1 else "users" - messages.success(request, f'Removed {remove_users_count} {user_plural} from allocation.') + messages.success(request, f"Removed {remove_users_count} {user_plural} from allocation.") else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + return redirect(allocation_obj) class AllocationAttributeCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = AllocationAttribute form_class = AllocationAttributeCreateForm - template_name = 'allocation/allocation_allocationattribute_create.html' + template_name = "allocation/allocation_allocationattribute_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - messages.error(self.request, 'You do not have permission to add allocation attributes.') + messages.error(self.request, "You do not have permission to add allocation attributes.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') - allocation_obj = get_object_or_404(Allocation, pk=pk) - context['allocation'] = allocation_obj + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation.objects.select_related("project", "project__pi"), pk=pk) + context["allocation"] = allocation_obj return context def get_initial(self): initial = super().get_initial() - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - initial['allocation'] = allocation_obj + initial["allocation"] = allocation_obj return initial def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" form = super().get_form(form_class) - form.fields['allocation'].widget = forms.HiddenInput() + form.fields["allocation"].widget = forms.HiddenInput() return form def get_success_url(self): - return reverse('allocation-detail', kwargs={'pk': self.kwargs.get('pk')}) + # can probably be replaced with `return self.object.allocation.get_absolute_url()` + return reverse("allocation-detail", kwargs={"pk": self.kwargs.get("pk")}) class AllocationAttributeDeleteView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_allocationattribute_delete.html' + template_name = "allocation/allocation_allocationattribute_delete.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - messages.error(self.request, 'You do not have permission to delete allocation attributes.') + messages.error(self.request, "You do not have permission to delete allocation attributes.") return False def get_allocation_attributes_to_delete(self, allocation_obj): - - allocation_attributes_to_delete = AllocationAttribute.objects.filter( - allocation=allocation_obj) + allocation_attributes_to_delete = AllocationAttribute.objects.select_related( + "allocation_attribute_type" + ).filter(allocation=allocation_obj) allocation_attributes_to_delete = [ - { - 'pk': attribute.pk, - 'name': attribute.allocation_attribute_type.name, - 'value': attribute.value, - } - + "pk": attribute.pk, + "name": attribute.allocation_attribute_type.name, + "value": attribute.value, + } for attribute in allocation_attributes_to_delete ] return allocation_attributes_to_delete def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - allocation_obj = get_object_or_404(Allocation, pk=pk) + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation.objects.select_related("project"), pk=pk) - allocation_attributes_to_delete = self.get_allocation_attributes_to_delete( - allocation_obj) + allocation_attributes_to_delete = self.get_allocation_attributes_to_delete(allocation_obj) context = {} if allocation_attributes_to_delete: - formset = formset_factory(AllocationAttributeDeleteForm, max_num=len( - allocation_attributes_to_delete)) - formset = formset( - initial=allocation_attributes_to_delete, prefix='attributeform') - context['formset'] = formset - context['allocation'] = allocation_obj + formset = formset_factory(AllocationAttributeDeleteForm, max_num=len(allocation_attributes_to_delete)) + formset = formset(initial=allocation_attributes_to_delete, prefix="attributeform") + context["formset"] = formset + context["allocation"] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - allocation_attributes_to_delete = self.get_allocation_attributes_to_delete( - allocation_obj) + allocation_attributes_to_delete = self.get_allocation_attributes_to_delete(allocation_obj) - formset = formset_factory(AllocationAttributeDeleteForm, max_num=len( - allocation_attributes_to_delete)) - formset = formset( - request.POST, initial=allocation_attributes_to_delete, prefix='attributeform') - - attributes_deleted_count = 0 + formset = formset_factory(AllocationAttributeDeleteForm, max_num=len(allocation_attributes_to_delete)) + formset = formset(request.POST, initial=allocation_attributes_to_delete, prefix="attributeform") if formset.is_valid(): + selected_attributes = [] for form in formset: form_data = form.cleaned_data - if form_data['selected']: - - attributes_deleted_count += 1 + if form_data.get("selected"): + selected_attributes.append(form_data.get("pk")) - allocation_attribute = AllocationAttribute.objects.get( - pk=form_data['pk']) - allocation_attribute.delete() + attributes_deleted_count = len(selected_attributes) + if attributes_deleted_count: + attribute_objs = AllocationAttribute.objects.filter(pk__in=selected_attributes) + attribute_objs.delete() - messages.success(request, f'Deleted {attributes_deleted_count} attributes from allocation.') + messages.success(request, f"Deleted {attributes_deleted_count} attributes from allocation.") else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + return redirect(allocation_obj) class AllocationNoteCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = AllocationUserNote - fields = '__all__' - template_name = 'allocation/allocation_note_create.html' + fields = "__all__" + template_name = "allocation/allocation_note_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - messages.error( self.request, 'You do not have permission to add allocation notes.') + messages.error(self.request, "You do not have permission to add allocation notes.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - context['allocation'] = allocation_obj + context["allocation"] = allocation_obj return context def get_initial(self): initial = super().get_initial() - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) author = self.request.user - initial['allocation'] = allocation_obj - initial['author'] = author + initial["allocation"] = allocation_obj + initial["author"] = author return initial def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" form = super().get_form(form_class) - form.fields['allocation'].widget = forms.HiddenInput() - form.fields['author'].widget = forms.HiddenInput() - form.order_fields([ 'allocation', 'author', 'note', 'is_private' ]) + form.fields["allocation"].widget = forms.HiddenInput() + form.fields["author"].widget = forms.HiddenInput() + form.order_fields(["allocation", "author", "note", "is_private"]) return form def get_success_url(self): - return reverse('allocation-detail', kwargs={'pk': self.kwargs.get('pk')}) + # can probably be replaced with `return self.object.allocation.get_absolute_url()` + return reverse("allocation-detail", kwargs={"pk": self.kwargs.get("pk")}) class AllocationRequestListView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_request_list.html' - login_url = '/' + template_name = "allocation/allocation_request_list.html" + login_url = "/" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_review_allocation_requests'): + if self.request.user.has_perm("allocation.can_review_allocation_requests"): return True - messages.error(self.request, 'You do not have permission to review allocation requests.') + messages.error(self.request, "You do not have permission to review allocation requests.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - allocation_list = Allocation.objects.filter( - status__name__in=['New', 'Renewal Requested', 'Paid', 'Approved',]) - context['allocation_status_active'] = AllocationStatusChoice.objects.get(name='Active') - context['allocation_list'] = allocation_list - context['PROJECT_ENABLE_PROJECT_REVIEW'] = PROJECT_ENABLE_PROJECT_REVIEW - context['ALLOCATION_DEFAULT_ALLOCATION_LENGTH'] = ALLOCATION_DEFAULT_ALLOCATION_LENGTH + allocation_list = Allocation.objects.select_related( + "status", "project", "project__pi", "project__status" + ).filter( + status__name__in=[ + "New", + "Renewal Requested", + "Paid", + "Approved", + ] + ) + + allocation_renewal_dates = {} + for allocation in allocation_list.filter(status__name="Renewal Requested"): + allocation_history = allocation.history.select_related("status").all().order_by("-history_date") + for history in allocation_history: + if history.status.name != "Renewal Requested": + break + allocation_renewal_dates[allocation.pk] = history.history_date + + context["allocation_renewal_dates"] = allocation_renewal_dates + context["allocation_status_active"] = AllocationStatusChoice.objects.get(name="Active") + context["allocation_list"] = allocation_list return context class AllocationRenewView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_renew.html' + template_name = "allocation/allocation_renew.html" def test_func(self): - """ UserPassesTestMixin Tests""" - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if allocation_obj.has_perm(self.request.user, AllocationPermission.MANAGER): return True - messages.error(self.request, 'You do not have permission to renew allocation.') + messages.error(self.request, "You do not have permission to renew allocation.") return False def dispatch(self, request, *args, **kwargs): - allocation_obj = get_object_or_404( - Allocation, pk=self.kwargs.get('pk')) + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if not ALLOCATION_ENABLE_ALLOCATION_RENEWAL: messages.error( - request, 'Allocation renewal is disabled. Request a new allocation to this resource if you want to continue using it after the active until date.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + request, + "Allocation renewal is disabled. Request a new allocation to this resource if you want to continue using it after the active until date.", + ) + return redirect(allocation_obj) - if allocation_obj.status.name not in ['Active', ]: - messages.error(request, f'You cannot renew a allocation with status {allocation_obj.status.name}.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + if allocation_obj.status.name not in [ + "Active", + ]: + messages.error(request, f"You cannot renew a allocation with status {allocation_obj.status.name}.") + return redirect(allocation_obj) if allocation_obj.project.needs_review: - messages.error( - request, 'You cannot renew your allocation because you have to review your project first.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': allocation_obj.project.pk})) + messages.error(request, "You cannot renew your allocation because you have to review your project first.") + return redirect(allocation_obj.project) if allocation_obj.expires_in > 60: - messages.error( - request, 'It is too soon to review your allocation.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + messages.error(request, "It is too soon to review your allocation.") + return redirect(allocation_obj) return super().dispatch(request, *args, **kwargs) def get_users_in_allocation(self, allocation_obj): - users_in_allocation = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed']).exclude(user__pk__in=[allocation_obj.project.pi.pk, self.request.user.pk]).order_by('user__username') + users_in_allocation = ( + allocation_obj.allocationuser_set.exclude(status__name__in=["Removed"]) + .exclude(user__pk__in=[allocation_obj.project.pi.pk, self.request.user.pk]) + .order_by("user__username") + ) users = [ - - {'username': allocation_user.user.username, - 'first_name': allocation_user.user.first_name, - 'last_name': allocation_user.user.last_name, - 'email': allocation_user.user.email, } - + { + "username": allocation_user.user.username, + "first_name": allocation_user.user.first_name, + "last_name": allocation_user.user.last_name, + "email": allocation_user.user.email, + } for allocation_user in users_in_allocation ] return users def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) users_in_allocation = self.get_users_in_allocation(allocation_obj) context = {} if users_in_allocation: - formset = formset_factory( - AllocationReviewUserForm, max_num=len(users_in_allocation)) - formset = formset(initial=users_in_allocation, prefix='userform') - context['formset'] = formset - - context['resource_eula'] = {} - if allocation_obj.get_parent_resource.resourceattribute_set.filter(resource_attribute_type__name='eula').exists(): - value = allocation_obj.get_parent_resource.resourceattribute_set.get(resource_attribute_type__name='eula').value - context['resource_eula'].update({'eula': value}) - - context['allocation'] = allocation_obj + formset = formset_factory(AllocationReviewUserForm, max_num=len(users_in_allocation)) + formset = formset(initial=users_in_allocation, prefix="userform") + context["formset"] = formset + + context["resource_eula"] = {} + if allocation_obj.get_parent_resource.resourceattribute_set.filter( + resource_attribute_type__name="eula" + ).exists(): + value = allocation_obj.get_parent_resource.resourceattribute_set.get( + resource_attribute_type__name="eula" + ).value + context["resource_eula"].update({"eula": value}) + + context["allocation"] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) users_in_allocation = self.get_users_in_allocation(allocation_obj) - formset = formset_factory( - AllocationReviewUserForm, max_num=len(users_in_allocation)) - formset = formset( - request.POST, initial=users_in_allocation, prefix='userform') + formset = formset_factory(AllocationReviewUserForm, max_num=len(users_in_allocation)) + formset = formset(request.POST, initial=users_in_allocation, prefix="userform") - allocation_renewal_requested_status_choice = AllocationStatusChoice.objects.get( - name='Renewal Requested') - allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get( - name='Removed') - project_user_remove_status_choice = ProjectUserStatusChoice.objects.get( - name='Removed') + allocation_renewal_requested_status_choice = AllocationStatusChoice.objects.get(name="Renewal Requested") allocation_obj.status = allocation_renewal_requested_status_choice allocation_obj.save() if not users_in_allocation or formset.is_valid(): - if users_in_allocation: for form in formset: user_form_data = form.cleaned_data - user_obj = get_user_model().objects.get( - username=user_form_data.get('username')) - user_status = user_form_data.get('user_status') - - if user_status == 'keep_in_project_only': - allocation_user_obj = allocation_obj.allocationuser_set.get( - user=user_obj) - allocation_user_obj.status = allocation_user_removed_status_choice - allocation_user_obj.save() - - allocation_remove_user.send( - sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) - - elif user_status == 'remove_from_project': - for active_allocation in allocation_obj.project.allocation_set.filter(status__name__in=( - 'Active', 'Denied', 'New', 'Paid', 'Payment Pending', - 'Payment Requested', 'Payment Declined', 'Renewal Requested', 'Unpaid',)): - - allocation_user_obj = active_allocation.allocationuser_set.get( - user=user_obj) - allocation_user_obj.status = allocation_user_removed_status_choice - allocation_user_obj.save() - allocation_remove_user.send( - sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) - - project_user_obj = ProjectUser.objects.get( - project=allocation_obj.project, - user=user_obj) - project_user_obj.status = project_user_remove_status_choice - project_user_obj.save() - - send_allocation_admin_email(allocation_obj, 'Allocation Renewed', 'email/allocation_renewed.txt', domain_url=get_domain_url(self.request)) - messages.success(request, 'Allocation renewed successfully') + user_obj = get_user_model().objects.get(username=user_form_data.get("username")) + user_status = user_form_data.get("user_status") + + if user_status == "keep_in_project_only": + allocation_obj.remove_user(user_obj, signal_sender=self.__class__) + + elif user_status == "remove_from_project": + allocation_obj.project.remove_user(user_obj, signal_sender=self.__class__) + + send_allocation_admin_email( + allocation_obj, + "Allocation Renewed", + "email/allocation_renewed.txt", + domain_url=get_domain_url(self.request), + ) + messages.success(request, "Allocation renewed successfully") else: if not formset.is_valid(): for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': allocation_obj.project.pk})) + return redirect(allocation_obj.project) class AllocationInvoiceListView(LoginRequiredMixin, UserPassesTestMixin, ListView): model = Allocation - template_name = 'allocation/allocation_invoice_list.html' - context_object_name = 'allocation_list' + template_name = "allocation/allocation_invoice_list.html" + context_object_name = "allocation_list" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_manage_invoice'): + if self.request.user.has_perm("allocation.can_manage_invoice"): return True - messages.error(self.request, 'You do not have permission to manage invoices.') + messages.error(self.request, "You do not have permission to manage invoices.") return False def get_queryset(self): - allocations = Allocation.objects.filter( - status__name__in=['Paid', 'Payment Pending', 'Payment Requested', 'Payment Declined', ]) + status__name__in=[ + "Paid", + "Payment Pending", + "Payment Requested", + "Payment Declined", + ] + ) return allocations + # this is the view class thats rendering allocation_invoice_detail. # each view class has a view template that renders class AllocationInvoiceDetailView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = Allocation - template_name = 'allocation/allocation_invoice_detail.html' - context_object_name = 'allocation' + template_name = "allocation/allocation_invoice_detail.html" + context_object_name = "allocation" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_manage_invoice'): + if self.request.user.has_perm("allocation.can_manage_invoice"): return True - messages.error(self.request, 'You do not have permission to view invoices.') + messages.error(self.request, "You do not have permission to view invoices.") return False def get_context_data(self, **kwargs): - """Create all the variables for allocation_invoice_detail.html - - """ + """Create all the variables for allocation_invoice_detail.html""" context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed']).order_by('user__username') + allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=["Removed"]).order_by( + "user__username" + ) alloc_attr_set = allocation_obj.get_attribute_set(self.request.user) - attributes_with_usage = [a for a in alloc_attr_set if hasattr(a, 'allocationattributeusage')] + attributes_with_usage = [a for a in alloc_attr_set if hasattr(a, "allocationattributeusage")] attributes = [a for a in alloc_attr_set] guage_data = [] invalid_attributes = [] for attribute in attributes_with_usage: try: - guage_data.append(generate_guauge_data_from_usage(attribute.allocation_attribute_type.name, - float(attribute.value), float(attribute.allocationattributeusage.value))) + guage_data.append( + generate_guauge_data_from_usage( + attribute.allocation_attribute_type.name, + float(attribute.value), + float(attribute.allocationattributeusage.value), + ) + ) except ValueError: - logger.error("Allocation attribute '%s' is not an int but has a usage", - attribute.allocation_attribute_type.name) + logger.error( + "Allocation attribute '%s' is not an int but has a usage", attribute.allocation_attribute_type.name + ) invalid_attributes.append(attribute) for a in invalid_attributes: attributes_with_usage.remove(a) - context['guage_data'] = guage_data - context['attributes_with_usage'] = attributes_with_usage - context['attributes'] = attributes + context["guage_data"] = guage_data + context["attributes_with_usage"] = attributes_with_usage + context["attributes"] = attributes # Can the user update the project? - context['is_allowed_to_update_project'] = allocation_obj.project.has_perm(self.request.user, ProjectPermission.UPDATE) - context['allocation_users'] = allocation_users + context["is_allowed_to_update_project"] = allocation_obj.project.has_perm( + self.request.user, ProjectPermission.UPDATE + ) + context["allocation_users"] = allocation_users if self.request.user.is_superuser: notes = allocation_obj.allocationusernote_set.all() else: - notes = allocation_obj.allocationusernote_set.filter( - is_private=False) + notes = allocation_obj.allocationusernote_set.filter(is_private=False) - context['notes'] = notes - context['ALLOCATION_ENABLE_ALLOCATION_RENEWAL'] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL + context["notes"] = notes return context - def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) initial_data = { - 'status': allocation_obj.status, + "status": allocation_obj.status, } form = AllocationInvoiceUpdateForm(initial=initial_data) context = self.get_context_data() - context['form'] = form - context['allocation'] = allocation_obj + context["form"] = form + context["allocation"] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - initial_data = {'status': allocation_obj.status,} + initial_data = { + "status": allocation_obj.status, + } form = AllocationInvoiceUpdateForm(request.POST, initial=initial_data) if form.is_valid(): form_data = form.cleaned_data - allocation_obj.status = form_data.get('status') + allocation_obj.status = form_data.get("status") allocation_obj.save() - messages.success(request, 'Allocation updated!') + messages.success(request, "Allocation updated!") else: for error in form.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('allocation-invoice-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("allocation-invoice-detail", kwargs={"pk": pk})) + class AllocationAddInvoiceNoteView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = AllocationUserNote - template_name = 'allocation/allocation_add_invoice_note.html' - fields = ('is_private', 'note',) + template_name = "allocation/allocation_add_invoice_note.html" + fields = ( + "is_private", + "note", + ) def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_manage_invoice'): + if self.request.user.has_perm("allocation.can_manage_invoice"): return True - messages.error(self.request, 'You do not have permission to manage invoices.') + messages.error(self.request, "You do not have permission to manage invoices.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - context['allocation'] = allocation_obj + context["allocation"] = allocation_obj return context def form_valid(self, form): # This method is called when valid form data has been POSTed. # It should return an HttpResponse. - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) obj = form.save(commit=False) obj.author = self.request.user @@ -1250,50 +1423,52 @@ def form_valid(self, form): return super().form_valid(form) def get_success_url(self): - return reverse_lazy('allocation-invoice-detail', kwargs={'pk': self.object.allocation.pk}) + return reverse_lazy("allocation-invoice-detail", kwargs={"pk": self.object.allocation.pk}) class AllocationUpdateInvoiceNoteView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): model = AllocationUserNote - template_name = 'allocation/allocation_update_invoice_note.html' - fields = ('is_private', 'note',) + template_name = "allocation/allocation_update_invoice_note.html" + fields = ( + "is_private", + "note", + ) def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_manage_invoice'): + if self.request.user.has_perm("allocation.can_manage_invoice"): return True - messages.error(self.request, 'You do not have permission to manage invoices.') + messages.error(self.request, "You do not have permission to manage invoices.") return False def get_success_url(self): - return reverse_lazy('allocation-invoice-detail', kwargs={'pk': self.object.allocation.pk}) + return reverse_lazy("allocation-invoice-detail", kwargs={"pk": self.object.allocation.pk}) class AllocationDeleteInvoiceNoteView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_delete_invoice_note.html' + template_name = "allocation/allocation_delete_invoice_note.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_manage_invoice'): + if self.request.user.has_perm("allocation.can_manage_invoice"): return True - messages.error(self.request, 'You do not have permission to manage invoices.') + messages.error(self.request, "You do not have permission to manage invoices.") return False def get_notes_to_delete(self, allocation_obj): - notes_to_delete = [ { - 'pk': note.pk, - 'note': note.note, - 'author': note.author.username, + "pk": note.pk, + "note": note.note, + "author": note.author.username, } for note in allocation_obj.allocationusernote_set.all() ] @@ -1301,97 +1476,92 @@ def get_notes_to_delete(self, allocation_obj): return notes_to_delete def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) notes_to_delete = self.get_notes_to_delete(allocation_obj) context = {} if notes_to_delete: - formset = formset_factory( - AllocationInvoiceNoteDeleteForm, max_num=len(notes_to_delete)) - formset = formset(initial=notes_to_delete, prefix='noteform') - context['formset'] = formset - context['allocation'] = allocation_obj + formset = formset_factory(AllocationInvoiceNoteDeleteForm, max_num=len(notes_to_delete)) + formset = formset(initial=notes_to_delete, prefix="noteform") + context["formset"] = formset + context["allocation"] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) notes_to_delete = self.get_notes_to_delete(allocation_obj) - formset = formset_factory( - AllocationInvoiceNoteDeleteForm, max_num=len(notes_to_delete)) - formset = formset( - request.POST, initial=notes_to_delete, prefix='noteform') + formset = formset_factory(AllocationInvoiceNoteDeleteForm, max_num=len(notes_to_delete)) + formset = formset(request.POST, initial=notes_to_delete, prefix="noteform") if formset.is_valid(): for form in formset: note_form_data = form.cleaned_data - if note_form_data['selected']: - note_obj = AllocationUserNote.objects.get( - pk=note_form_data.get('pk')) + if note_form_data["selected"]: + note_obj = AllocationUserNote.objects.get(pk=note_form_data.get("pk")) note_obj.delete() else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse_lazy('allocation-invoice-detail', kwargs={'pk': allocation_obj.pk})) + return HttpResponseRedirect(reverse_lazy("allocation-invoice-detail", kwargs={"pk": allocation_obj.pk})) class AllocationAccountCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = AllocationAccount - template_name = 'allocation/allocation_allocationaccount_create.html' + template_name = "allocation/allocation_allocationaccount_create.html" form_class = AllocationAccountForm def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" - if not ALLOCATION_ACCOUNT_ENABLED: + if not settings.ALLOCATION_ACCOUNT_ENABLED: return False if self.request.user.is_superuser: return True if self.request.user.userprofile.is_pi: return True - messages.error(self.request, 'You do not have permission to add allocation attributes.') + messages.error(self.request, "You do not have permission to add allocation attributes.") return False def form_invalid(self, form): response = super().form_invalid(form) - if self.request.is_ajax(): + if self.request.headers.get("x-requested-with") == "XMLHttpRequest": return JsonResponse(form.errors, status=400) return response def form_valid(self, form): form.instance.user = self.request.user response = super().form_valid(form) - if self.request.is_ajax(): + if self.request.headers.get("x-requested-with") == "XMLHttpRequest": data = { - 'pk': self.object.pk, + "pk": self.object.pk, } return JsonResponse(data) return response def get_success_url(self): - return reverse_lazy('allocation-account-list') + return reverse_lazy("allocation-account-list") class AllocationAccountListView(LoginRequiredMixin, UserPassesTestMixin, ListView): model = AllocationAccount - template_name = 'allocation/allocation_account_list.html' - context_object_name = 'allocationaccount_list' + template_name = "allocation/allocation_account_list.html" + context_object_name = "allocationaccount_list" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" - if not ALLOCATION_ACCOUNT_ENABLED: + if not settings.ALLOCATION_ACCOUNT_ENABLED: return False if self.request.user.is_superuser: return True if self.request.user.userprofile.is_pi: return True - messages.error(self.request, 'You do not have permission to manage invoices.') + messages.error(self.request, "You do not have permission to manage invoices.") return False def get_queryset(self): @@ -1399,14 +1569,20 @@ def get_queryset(self): class AllocationChangeDetailView(LoginRequiredMixin, UserPassesTestMixin, FormView): + """ + Allows a superuser to approve or deny an AllocationChangeRequest + Allows a superuser to update the end_date_extension or notes of an AllocationChangeRequest + See AllocationAttributeEditView for updating an AllocationChangeRequest's AllocationAttributeChangeRequest + """ + formset_class = AllocationAttributeUpdateForm - template_name = 'allocation/allocation_change_detail.html' + template_name = "allocation/allocation_change_detail.html" def test_func(self): - """ UserPassesTestMixin Tests""" - allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=self.kwargs.get("pk")) - if self.request.user.has_perm('allocation.can_view_all_allocations'): + if self.request.user.has_perm("allocation.can_view_all_allocations"): return True if allocation_change_obj.allocation.has_perm(self.request.user, AllocationPermission.MANAGER): @@ -1414,19 +1590,18 @@ def test_func(self): return False - def get_allocation_attributes_to_change(self, allocation_change_obj): + """Find all allocation change requests for the specified allocation, format as list of dicts""" attributes_to_change = allocation_change_obj.allocationattributechangerequest_set.all() attributes_to_change = [ - - {'change_pk': attribute_change.pk, - 'attribute_pk': attribute_change.allocation_attribute.pk, - 'name': attribute_change.allocation_attribute.allocation_attribute_type.name, - 'value': attribute_change.allocation_attribute.value, - 'new_value': attribute_change.new_value, - } - + { + "change_pk": attribute_change.pk, + "attribute_pk": attribute_change.allocation_attribute.pk, + "name": attribute_change.allocation_attribute.allocation_attribute_type.name, + "value": attribute_change.allocation_attribute.value, + "new_value": attribute_change.new_value, + } for attribute_change in attributes_to_change ] @@ -1435,114 +1610,111 @@ def get_allocation_attributes_to_change(self, allocation_change_obj): def get_context_data(self, **kwargs): context = {} - allocation_change_obj = get_object_or_404( - AllocationChangeRequest, pk=self.kwargs.get('pk')) + allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=self.kwargs.get("pk")) - - allocation_attributes_to_change = self.get_allocation_attributes_to_change( - allocation_change_obj) + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_change_obj) if allocation_attributes_to_change: - formset = formset_factory(self.formset_class, max_num=len( - allocation_attributes_to_change)) - formset = formset( - initial=allocation_attributes_to_change, prefix='attributeform') - context['formset'] = formset + formset = formset_factory(self.formset_class, max_num=len(allocation_attributes_to_change)) + formset = formset(initial=allocation_attributes_to_change, prefix="attributeform") + context["formset"] = formset - context['allocation_change'] = allocation_change_obj - context['attribute_changes'] = allocation_attributes_to_change + context["allocation_change"] = allocation_change_obj + context["attribute_changes"] = allocation_attributes_to_change return context def get(self, request, *args, **kwargs): - - allocation_change_obj = get_object_or_404( - AllocationChangeRequest, pk=self.kwargs.get('pk')) + allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=self.kwargs.get("pk")) allocation_change_form = AllocationChangeForm( - initial={'justification': allocation_change_obj.justification, - 'end_date_extension': allocation_change_obj.end_date_extension}) - allocation_change_form.fields['justification'].disabled = True - if allocation_change_obj.status.name != 'Pending': - allocation_change_form.fields['end_date_extension'].disabled = True + initial={ + "justification": allocation_change_obj.justification, + "end_date_extension": allocation_change_obj.end_date_extension, + } + ) + allocation_change_form.fields["justification"].disabled = True + if allocation_change_obj.status.name != "Pending": + allocation_change_form.fields["end_date_extension"].disabled = True if not self.request.user.is_staff and not self.request.user.is_superuser: - allocation_change_form.fields['end_date_extension'].disabled = True + allocation_change_form.fields["end_date_extension"].disabled = True - note_form = AllocationChangeNoteForm( - initial={'notes': allocation_change_obj.notes}) + note_form = AllocationChangeNoteForm(initial={"notes": allocation_change_obj.notes}) context = self.get_context_data() - context['allocation_change_form'] = allocation_change_form - context['note_form'] = note_form + context["allocation_change_form"] = allocation_change_form + context["note_form"] = note_form return render(request, self.template_name, context) - def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") if not self.request.user.is_superuser: - messages.error( - request, 'You do not have permission to update an allocation change request') - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + messages.error(request, "You do not have permission to update an allocation change request") + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=pk) - allocation_change_form = AllocationChangeForm(request.POST, - initial={'justification': allocation_change_obj.justification, - 'end_date_extension': allocation_change_obj.end_date_extension}) - allocation_change_form.fields['justification'].required = False + allocation_change_form = AllocationChangeForm( + request.POST, + initial={ + "justification": allocation_change_obj.justification, + "end_date_extension": allocation_change_obj.end_date_extension, + }, + ) + allocation_change_form.fields["justification"].required = False - allocation_attributes_to_change = self.get_allocation_attributes_to_change( - allocation_change_obj) + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_change_obj) if allocation_attributes_to_change: - formset = formset_factory(self.formset_class, max_num=len( - allocation_attributes_to_change)) - formset = formset( - request.POST, initial=allocation_attributes_to_change, prefix='attributeform') + formset = formset_factory(self.formset_class, max_num=len(allocation_attributes_to_change)) + formset = formset(request.POST, initial=allocation_attributes_to_change, prefix="attributeform") - note_form = AllocationChangeNoteForm( - request.POST, initial={'notes': allocation_change_obj.notes}) + note_form = AllocationChangeNoteForm(request.POST, initial={"notes": allocation_change_obj.notes}) if not note_form.is_valid(): allocation_change_form = AllocationChangeForm( - initial={'justification': allocation_change_obj.justification}) - allocation_change_form.fields['justification'].disabled = True + initial={"justification": allocation_change_obj.justification} + ) + allocation_change_form.fields["justification"].disabled = True context = self.get_context_data() - context['note_form'] = note_form - context['allocation_change_form'] = allocation_change_form + context["note_form"] = note_form + context["allocation_change_form"] = allocation_change_form return render(request, self.template_name, context) - notes = note_form.cleaned_data.get('notes') + notes = note_form.cleaned_data.get("notes") - action = request.POST.get('action') - if action not in ['update', 'approve', 'deny']: + action = request.POST.get("action") + if action not in ["update", "approve", "deny"]: return HttpResponseBadRequest("Invalid request") - if action == 'deny': + if action == "deny": allocation_change_obj.notes = notes - allocation_change_status_denied_obj = AllocationChangeStatusChoice.objects.get( - name='Denied') + allocation_change_status_denied_obj = AllocationChangeStatusChoice.objects.get(name="Denied") allocation_change_obj.status = allocation_change_status_denied_obj allocation_change_obj.save() - messages.success(request, 'Allocation change request to {} has been DENIED for {} {} ({})'.format( - allocation_change_obj.allocation.resources.first(), - allocation_change_obj.allocation.project.pi.first_name, - allocation_change_obj.allocation.project.pi.last_name, - allocation_change_obj.allocation.project.pi.username) + messages.success( + request, + "Allocation change request to {} has been DENIED for {} {} ({})".format( + allocation_change_obj.allocation.resources.first(), + allocation_change_obj.allocation.project.pi.first_name, + allocation_change_obj.allocation.project.pi.last_name, + allocation_change_obj.allocation.project.pi.username, + ), ) - send_allocation_customer_email(allocation_change_obj.allocation, - 'Allocation Change Denied', - 'email/allocation_change_denied.txt', - domain_url=get_domain_url(self.request)) - - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + send_allocation_customer_email( + allocation_change_obj.allocation, + "Allocation Change Denied", + "email/allocation_change_denied.txt", + domain_url=get_domain_url(self.request), + ) + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) if not allocation_change_form.is_valid() or (allocation_attributes_to_change and not formset.is_valid()): for error in allocation_change_form.errors: @@ -1551,25 +1723,23 @@ def post(self, request, *args, **kwargs): attribute_errors = "" for error in formset.errors: if error: - attribute_errors += error.get('__all__') + attribute_errors += error.get("__all__") messages.error(request, attribute_errors) - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) - + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) allocation_change_obj.notes = notes - if action == 'update' and allocation_change_obj.status.name != 'Pending': + if action == "update" and allocation_change_obj.status.name != "Pending": allocation_change_obj.save() - messages.success(request, 'Allocation change request updated!') - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) - + messages.success(request, "Allocation change request updated!") + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) form_data = allocation_change_form.cleaned_data - end_date_extension = form_data.get('end_date_extension') + end_date_extension = form_data.get("end_date_extension") if not allocation_attributes_to_change and end_date_extension == 0: - messages.error(request, 'You must make a change to the allocation.') - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + messages.error(request, "You must make a change to the allocation.") + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) if end_date_extension != allocation_change_obj.end_date_extension: allocation_change_obj.end_date_extension = end_date_extension @@ -1577,29 +1747,25 @@ def post(self, request, *args, **kwargs): if allocation_attributes_to_change: for entry in formset: formset_data = entry.cleaned_data - new_value = formset_data.get('new_value') - attribute_change = AllocationAttributeChangeRequest.objects.get( - pk=formset_data.get('change_pk')) + new_value = formset_data.get("new_value") + attribute_change = AllocationAttributeChangeRequest.objects.get(pk=formset_data.get("change_pk")) if new_value != attribute_change.new_value: attribute_change.new_value = new_value attribute_change.save() - - if action == 'update': - + if action == "update": allocation_change_obj.save() - messages.success(request, 'Allocation change request updated!') + messages.success(request, "Allocation change request updated!") - - elif action == 'approve': - allocation_change_status_active_obj = AllocationChangeStatusChoice.objects.get( - name='Approved') + elif action == "approve": + allocation_change_status_active_obj = AllocationChangeStatusChoice.objects.get(name="Approved") allocation_change_obj.status = allocation_change_status_active_obj if allocation_change_obj.end_date_extension > 0: new_end_date = allocation_change_obj.allocation.end_date + relativedelta( - days=allocation_change_obj.end_date_extension) + days=allocation_change_obj.end_date_extension + ) allocation_change_obj.allocation.end_date = new_end_date allocation_change_obj.allocation.save() @@ -1610,103 +1776,129 @@ def post(self, request, *args, **kwargs): for attribute_change in attribute_change_list: attribute_change.allocation_attribute.value = attribute_change.new_value attribute_change.allocation_attribute.save() + allocation_attribute_changed.send( + sender=self.__class__, + attribute_pk=attribute_change.allocation_attribute.pk, + allocation_pk=allocation_change_obj.allocation.pk, + ) - messages.success(request, 'Allocation change request to {} has been APPROVED for {} {} ({})'.format( - allocation_change_obj.allocation.get_parent_resource, - allocation_change_obj.allocation.project.pi.first_name, - allocation_change_obj.allocation.project.pi.last_name, - allocation_change_obj.allocation.project.pi.username) + messages.success( + request, + "Allocation change request to {} has been APPROVED for {} {} ({})".format( + allocation_change_obj.allocation.get_parent_resource, + allocation_change_obj.allocation.project.pi.first_name, + allocation_change_obj.allocation.project.pi.last_name, + allocation_change_obj.allocation.project.pi.username, + ), ) allocation_change_approved.send( sender=self.__class__, allocation_pk=allocation_change_obj.allocation.pk, - allocation_change_pk=allocation_change_obj.pk,) - - send_allocation_customer_email(allocation_change_obj.allocation, - 'Allocation Change Approved', - 'email/allocation_change_approved.txt', - domain_url=get_domain_url(self.request)) - - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + allocation_change_pk=allocation_change_obj.pk, + ) + send_allocation_customer_email( + allocation_change_obj.allocation, + "Allocation Change Approved", + "email/allocation_change_approved.txt", + domain_url=get_domain_url(self.request), + ) + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) class AllocationChangeListView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_change_list.html' + template_name = "allocation/allocation_change_list.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_review_allocation_requests'): + if self.request.user.has_perm("allocation.can_review_allocation_requests"): return True - messages.error(self.request, 'You do not have permission to review allocation requests.') + messages.error(self.request, "You do not have permission to review allocation requests.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - allocation_change_list = AllocationChangeRequest.objects.filter( - status__name__in=['Pending', ]) - context['allocation_change_list'] = allocation_change_list - context['PROJECT_ENABLE_PROJECT_REVIEW'] = PROJECT_ENABLE_PROJECT_REVIEW + allocation_change_list = AllocationChangeRequest.objects.select_related( + "allocation", "allocation__project", "allocation__project__pi" + ).filter( + status__name__in=[ + "Pending", + ] + ) + context["allocation_change_list"] = allocation_change_list return context class AllocationChangeView(LoginRequiredMixin, UserPassesTestMixin, FormView): + """Allows a user with manager permissions to create an allocation change request""" + formset_class = AllocationAttributeChangeForm - template_name = 'allocation/allocation_change.html' + template_name = "allocation/allocation_change.html" def test_func(self): - """ UserPassesTestMixin Tests""" - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if allocation_obj.has_perm(self.request.user, AllocationPermission.MANAGER): return True - messages.error(self.request, 'You do not have permission to request changes to this allocation.') + messages.error(self.request, "You do not have permission to request changes to this allocation.") return False def dispatch(self, request, *args, **kwargs): - allocation_obj = get_object_or_404( - Allocation, pk=self.kwargs.get('pk')) + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if allocation_obj.project.needs_review: messages.error( - request, 'You cannot request a change to this allocation because you have to review your project first.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + request, "You cannot request a change to this allocation because you have to review your project first." + ) + return redirect(allocation_obj) - if allocation_obj.project.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot request a change to an allocation in an archived project.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + if allocation_obj.project.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot request a change to an allocation in an archived project.") + return redirect(allocation_obj) if allocation_obj.is_locked: + messages.error(request, "You cannot request a change to a locked allocation.") + return redirect(allocation_obj) + + if allocation_obj.status.name not in [ + "Active", + "Renewal Requested", + "Payment Pending", + "Payment Requested", + "Paid", + ]: messages.error( - request, 'You cannot request a change to a locked allocation.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) - - if allocation_obj.status.name not in ['Active', 'Renewal Requested', 'Payment Pending', 'Payment Requested', 'Paid']: - messages.error(request, f'You cannot request a change to an allocation with status "{allocation_obj.status.name}".') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + request, f'You cannot request a change to an allocation with status "{allocation_obj.status.name}".' + ) + return redirect(allocation_obj) return super().dispatch(request, *args, **kwargs) def get_allocation_attributes_to_change(self, allocation_obj): + """Find all changeable attributes for the specified allocation, format as list of dicts""" attributes_to_change = allocation_obj.allocationattribute_set.filter( - allocation_attribute_type__is_changeable=True) + allocation_attribute_type__is_changeable=True + ) attributes_to_change = [ { - 'pk': attribute.pk, - 'name': attribute.allocation_attribute_type.name, - 'value': attribute.value, - } + "pk": attribute.pk, + "name": attribute.allocation_attribute_type.name, + "value": attribute.value, + } for attribute in attributes_to_change ] @@ -1715,123 +1907,223 @@ def get_allocation_attributes_to_change(self, allocation_obj): def get(self, request, *args, **kwargs): context = {} - allocation_obj = get_object_or_404( - Allocation, pk=self.kwargs.get('pk')) + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) form = AllocationChangeForm(**self.get_form_kwargs()) - context['form'] = form + context["form"] = form allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) if allocation_attributes_to_change: - formset = formset_factory(self.formset_class, max_num=len( - allocation_attributes_to_change)) - formset = formset( - initial=allocation_attributes_to_change, prefix='attributeform') - context['formset'] = formset - context['allocation'] = allocation_obj - context['attributes'] = allocation_attributes_to_change + formset = formset_factory(self.formset_class, max_num=len(allocation_attributes_to_change)) + formset = formset(initial=allocation_attributes_to_change, prefix="attributeform") + context["formset"] = formset + context["allocation"] = allocation_obj + context["attributes"] = allocation_attributes_to_change return render(request, self.template_name, context) def post(self, request, *args, **kwargs): change_requested = False attribute_changes_to_make = set({}) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) form = AllocationChangeForm(**self.get_form_kwargs()) - allocation_attributes_to_change = self.get_allocation_attributes_to_change( - allocation_obj) + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) if allocation_attributes_to_change: - formset = formset_factory(self.formset_class, max_num=len( - allocation_attributes_to_change)) - formset = formset( - request.POST, initial=allocation_attributes_to_change, prefix='attributeform') + formset = formset_factory(self.formset_class, max_num=len(allocation_attributes_to_change)) + formset = formset(request.POST, initial=allocation_attributes_to_change, prefix="attributeform") if not form.is_valid() or not formset.is_valid(): attribute_errors = "" for error in form.errors: messages.error(request, error) for error in formset.errors: - if error: attribute_errors += error.get('__all__') + if error: + attribute_errors += error.get("__all__") messages.error(request, attribute_errors) - return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("allocation-change", kwargs={"pk": pk})) form_data = form.cleaned_data - if form_data.get('end_date_extension') != 0: + if form_data.get("end_date_extension") != 0: change_requested = True for entry in formset: formset_data = entry.cleaned_data - new_value = formset_data.get('new_value') + new_value = formset_data.get("new_value") if new_value != "": change_requested = True - allocation_attribute = AllocationAttribute.objects.get(pk=formset_data.get('pk')) + allocation_attribute = AllocationAttribute.objects.get(pk=formset_data.get("pk")) attribute_changes_to_make.add((allocation_attribute, new_value)) if not change_requested: - messages.error(request, 'You must request a change.') - return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) - + messages.error(request, "You must request a change.") + return HttpResponseRedirect(reverse("allocation-change", kwargs={"pk": pk})) if not form.is_valid(): for error in form.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("allocation-change", kwargs={"pk": pk})) form_data = form.cleaned_data - if not allocation_attributes_to_change and form_data.get('end_date_extension') == 0: - messages.error(request, 'You must request a change.') - return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) + if not allocation_attributes_to_change and form_data.get("end_date_extension") == 0: + messages.error(request, "You must request a change.") + return HttpResponseRedirect(reverse("allocation-change", kwargs={"pk": pk})) - end_date_extension = form_data.get('end_date_extension') - justification = form_data.get('justification') - change_request_status_obj = AllocationChangeStatusChoice.objects.get(name='Pending') + end_date_extension = form_data.get("end_date_extension") + justification = form_data.get("justification") + change_request_status_obj = AllocationChangeStatusChoice.objects.get(name="Pending") allocation_change_request_obj = AllocationChangeRequest.objects.create( allocation=allocation_obj, end_date_extension=end_date_extension, justification=justification, - status=change_request_status_obj - ) - + status=change_request_status_obj, + ) for attribute in attribute_changes_to_make: - attribute_change_request_obj = AllocationAttributeChangeRequest.objects.create( + AllocationAttributeChangeRequest.objects.create( allocation_change_request=allocation_change_request_obj, allocation_attribute=attribute[0], - new_value=attribute[1] - ) + new_value=attribute[1], + ) + + messages.success(request, "Allocation change request successfully submitted.") + + allocation_change_created.send( + sender=self.__class__, + allocation_pk=allocation_obj.pk, + allocation_change_pk=allocation_change_request_obj.pk, + ) + + send_allocation_admin_email( + allocation_obj, + "New Allocation Change Request", + "email/new_allocation_change_request.txt", + url_path=reverse("allocation-change-list"), + domain_url=get_domain_url(self.request), + ) + return redirect(allocation_obj) + + +class AllocationAttributeEditView(LoginRequiredMixin, UserPassesTestMixin, FormView): + formset_class = AllocationAttributeEditForm + template_name = "allocation/allocation_attribute_edit.html" + + def test_func(self): + """UserPassesTestMixin Tests""" + user = self.request.user + if user.is_superuser or user.is_staff: + return True + + messages.error(self.request, "You do not have permission to edit this allocation's attributes.") + + return False - messages.success(request, 'Allocation change request successfully submitted.') + def get_allocation_attributes_to_change(self, allocation_obj): + attributes_to_change = allocation_obj.allocationattribute_set.select_related("allocation_attribute_type").all() + + attributes_to_change = [ + { + "attribute_pk": attribute.pk, + "name": attribute.allocation_attribute_type.name, + "orig_value": attribute.value, + "value": attribute.value, + } + for attribute in attributes_to_change + ] + + return attributes_to_change + + def get(self, request, *args, **kwargs): + context = {} + allocation_obj = get_object_or_404(Allocation.objects.select_related("project"), pk=self.kwargs.get("pk")) + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) + context["allocation"] = allocation_obj + + if not allocation_attributes_to_change: + return render(request, self.template_name, context) + + AllocAttrChangeFormsetFactory = formset_factory( + self.formset_class, + max_num=len(allocation_attributes_to_change), + ) + formset = AllocAttrChangeFormsetFactory( + initial=allocation_attributes_to_change, + prefix="attributeform", + ) + context["formset"] = formset + context["attributes"] = allocation_attributes_to_change + return render(request, self.template_name, context) + + def post(self, request, *args, **kwargs): + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) + + ok_redirect = redirect(allocation_obj) + + if not allocation_attributes_to_change: + return ok_redirect + + AllocAttrChangeFormsetFactory = formset_factory( + self.formset_class, + max_num=len(allocation_attributes_to_change), + ) + formset = AllocAttrChangeFormsetFactory( + request.POST, + initial=allocation_attributes_to_change, + prefix="attributeform", + ) + if not formset.is_valid(): + attribute_errors = "" + for error in formset.errors: + if error: + attribute_errors += error.get("__all__") + messages.error(request, attribute_errors) + error_redirect = HttpResponseRedirect(reverse("allocation-attribute-edit", kwargs={"pk": pk})) + return error_redirect + + attribute_changes_to_make_pks = dict() + for entry in formset: + formset_data = entry.cleaned_data + value = formset_data.get("value") + orig_value = formset_data.get("orig_value") + + if not value == "" and not value == orig_value: + attribute_changes_to_make_pks[formset_data.get("attribute_pk")] = value + + for allocation_attribute in AllocationAttribute.objects.filter(pk__in=attribute_changes_to_make_pks.keys()): + allocation_attribute.value = attribute_changes_to_make_pks.get(allocation_attribute.pk) + allocation_attribute.save() + allocation_attribute_changed.send( + sender=self.__class__, + attribute_pk=allocation_attribute.pk, + allocation_pk=pk, + ) - send_allocation_admin_email(allocation_obj, - 'New Allocation Change Request', - 'email/new_allocation_change_request.txt', - url_path=reverse('allocation-change-list'), - domain_url=get_domain_url(self.request)) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + return ok_redirect class AllocationChangeDeleteAttributeView(LoginRequiredMixin, UserPassesTestMixin, View): - login_url = '/' + login_url = "/" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_review_allocation_requests'): + if self.request.user.has_perm("allocation.can_review_allocation_requests"): return True - messages.error(self.request, 'You do not have permission to update an allocation change request.') + messages.error(self.request, "You do not have permission to update an allocation change request.") return False def get(self, request, pk): @@ -1840,6 +2132,5 @@ def get(self, request, pk): allocation_attribute_change_obj.delete() - messages.success( - request, 'Allocation attribute change request successfully deleted.') - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': allocation_change_pk})) + messages.success(request, "Allocation attribute change request successfully deleted.") + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": allocation_change_pk})) diff --git a/coldfront/core/attribute_expansion.py b/coldfront/core/attribute_expansion.py index ff985b2d89..68260536f2 100644 --- a/coldfront/core/attribute_expansion.py +++ b/coldfront/core/attribute_expansion.py @@ -1,21 +1,24 @@ -#Collection of functions related to attribute expansion. +# SPDX-FileCopyrightText: (C) ColdFront Authors # -#This is a collection common functions related to the expansion -#of parameters (typically related to other attributes) inside of -#attributes. Used in the expanded_value() method of AllocationAttribute -#and ResourceAttribute. +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Collection of functions related to attribute expansion. +# +# This is a collection common functions related to the expansion +# of parameters (typically related to other attributes) inside of +# attributes. Used in the expanded_value() method of AllocationAttribute +# and ResourceAttribute. import logging import math - logger = logging.getLogger(__name__) -#ALLOCATION_ATTRIBUTE_VIEW_LIST = import_from_settings( +# ALLOCATION_ATTRIBUTE_VIEW_LIST = import_from_settings( # 'ALLOCATION_ATTRIBUTE_VIEW_LIST', []) -ATTRIBUTE_EXPANSION_TYPE_PREFIX = 'Attribute Expanded' -ATTRIBUTE_EXPANSION_ATTRIBLIST_SUFFIX = '_attriblist' +ATTRIBUTE_EXPANSION_TYPE_PREFIX = "Attribute Expanded" +ATTRIBUTE_EXPANSION_ATTRIBLIST_SUFFIX = "_attriblist" def is_expandable_type(attribute_type): @@ -23,13 +26,14 @@ def is_expandable_type(attribute_type): Takes an AttributeType (from either Resource or Allocation, but wants AttributeType, not ResourceAttributeType or - AllocationAttributeType) and checks if type name matches + AllocationAttributeType) and checks if type name matches ATTRIBUTE_EXPANSION_TYPE_PREFIX """ - atype_name = attribute_type.name; + atype_name = attribute_type.name return atype_name.startswith(ATTRIBUTE_EXPANSION_TYPE_PREFIX) + def get_attriblist_str(attribute_name, resources=[], allocations=[]): """This finds the attriblist string for the named expandable attribute. @@ -40,8 +44,7 @@ def get_attriblist_str(attribute_name, resources=[], allocations=[]): attriblist attributes are found, we return None. """ - attriblist_name = "{aname}{suffix}".format( - aname=attribute_name, suffix=ATTRIBUTE_EXPANSION_ATTRIBLIST_SUFFIX) + attriblist_name = "{aname}{suffix}".format(aname=attribute_name, suffix=ATTRIBUTE_EXPANSION_ATTRIBLIST_SUFFIX) attriblist = None # Check resources first @@ -49,7 +52,7 @@ def get_attriblist_str(attribute_name, resources=[], allocations=[]): alist_list = res.get_attribute_list(attriblist_name) for alist in alist_list: if attriblist: - attriblist = attriblist + '\n' + alist + attriblist = attriblist + "\n" + alist else: attriblist = alist # Then check allocations @@ -57,15 +60,14 @@ def get_attriblist_str(attribute_name, resources=[], allocations=[]): alist_list = alloc.get_attribute_list(attriblist_name) for alist in alist_list: if attriblist: - attriblist = attriblist + '\n' + alist + attriblist = attriblist + "\n" + alist else: attriblist = alist return attriblist -def get_attribute_parameter_value( - argument, attribute_parameter_dict, error_text, - resources=[], allocations=[]): + +def get_attribute_parameter_value(argument, attribute_parameter_dict, error_text, resources=[], allocations=[]): """Evaluates the argument for a attribute parameter statement. This is called by process_attribute_parameter_string and handles @@ -75,7 +77,7 @@ def get_attribute_parameter_value( APDICT:pname - expands to the value of a parameter named pname already in the attribute_parameter_dict (or None if not present) RESOURCE:aname - expands to the value of the first attribute of type - named aname found in the resources list of resources. + named aname found in the resources list of resources. NOTE: 'RESOURCE:' is a literal. Or None if not found. ALLOCATION:aname - expands to the value of the first attribute of type named aname found in the allocations list of allocations. @@ -88,17 +90,17 @@ def get_attribute_parameter_value( 'single line of text' - expands to a string literal contained between the two single quotes. Very simplistic, nothing is allowed after the last single quote, and we just remove the leading and trailing - single quote --- everything in between is treated literally + single quote --- everything in between is treated literally (including any contained single quotes). digits (optionally with decimal): expands to a numeric literal error_text is used to give context in diagnostic messages. - This method returns the expanded value, or None if unable to + This method returns the expanded value, or None if unable to evaluate """ value = None - + # Check for string constant if argument.startswith("'"): # Looks like a string literal @@ -107,20 +109,22 @@ def get_attribute_parameter_value( # Verify the last character is a single quote tmp = tmpstr[-1:] if tmp == "'": - #Good string literal + # Good string literal tmpstr = tmpstr[:-1] return tmpstr else: - #Bad string literal - logger.warn("Bad string literal '{}' found while processing " - "{}; missing final single quote".format( - argument, error_text)) + # Bad string literal + logger.warning( + "Bad string literal '{}' found while processing {}; missing final single quote".format( + argument, error_text + ) + ) return None # If argument if prefixed with any of the strings in attrib_sources, # strip the prefix and set attrib_source accordingly attrib_source = None - attrib_sources = [ ':APDICT', 'RESOURCE:', 'ALLOCATION:', ':' ] + attrib_sources = [":APDICT", "RESOURCE:", "ALLOCATION:", ":"] for asrc in attrib_sources: if argument.startswith(asrc): # Got a match @@ -132,18 +136,17 @@ def get_attribute_parameter_value( # Try expanding as a parameter/attribute # We do attribute_parameter_dict first, then allocations, then # resources to try to get value most specific to use case - if ( attribute_parameter_dict is not None and - (attrib_source == ':' or attrib_source == 'APDICT:')): + if attribute_parameter_dict is not None and (attrib_source == ":" or attrib_source == "APDICT:"): if argument in attribute_parameter_dict: return attribute_parameter_dict[argument] - if attrib_source == ':' or attrib_source == 'ALLOCATION:': + if attrib_source == ":" or attrib_source == "ALLOCATION:": for alloc in allocations: tmp = alloc.get_attribute(argument) if tmp is not None: return tmp - if attrib_source == ':' or attrib_source == 'RESOURCE:': + if attrib_source == ":" or attrib_source == "RESOURCE:": for res in resources: tmp = res.get_attribute(argument) if tmp is not None: @@ -154,7 +157,7 @@ def get_attribute_parameter_value( # find it. Just return None return None - # If reach here, argument is not a string literal, or a + # If reach here, argument is not a string literal, or a # parameter or attribute name, so try numeric constant try: value = int(argument) @@ -164,16 +167,18 @@ def get_attribute_parameter_value( value = float(argument) return value except ValueError: - logger.warn("Unable to evaluate argument '{arg}' while " - "processing {etxt}, returning None".format( - arg=argument, etxt=error_text)) + logger.warning( + "Unable to evaluate argument '{arg}' while processing {etxt}, returning None".format( + arg=argument, etxt=error_text + ) + ) return None # Should not reach here return None - -def process_attribute_parameter_operation( - opcode, oldvalue, argument, error_text): + + +def process_attribute_parameter_operation(opcode, oldvalue, argument, error_text): """Process the specified operation for attribute_parameter_dict. This is called by process_attribute_parameter_string and handles @@ -209,27 +214,25 @@ def process_attribute_parameter_operation( """ # Argument should never be None if argument is None: - logger.warn("Operator {}= acting on None argument in {}, " - "returning None".format(opcode, error_text)) + logger.warning("Operator {}= acting on None argument in {}, returning None".format(opcode, error_text)) return None # Assignment and default operations allow oldvalue to be None if oldvalue is None: - if opcode != ':' and opcode != '|': - logger.warn("Operator {}= acting on oldvalue=None in {}, " - "returning None".format(opcode, error_text)) + if opcode != ":" and opcode != "|": + logger.warning("Operator {}= acting on oldvalue=None in {}, returning None".format(opcode, error_text)) return None try: - if opcode == ':': + if opcode == ":": # Assignment operation return argument - if opcode == '|': + if opcode == "|": # Defaulting operation if oldvalue is None: return argument else: return oldvalue - if opcode == '+': + if opcode == "+": # Addition/concatenation operation if isinstance(oldvalue, int) or isinstance(oldvalue, float): newval = oldvalue + argument @@ -238,40 +241,43 @@ def process_attribute_parameter_operation( newval = oldvalue + argument return newval else: - logger.warn('Operator {}= acting on parameter of type ' - '{} in {}, returning None'.format( - opcode, type(oldvalue), error_text)) + logger.warning( + "Operator {}= acting on parameter of type {} in {}, returning None".format( + opcode, type(oldvalue), error_text + ) + ) return None - if opcode == '-': + if opcode == "-": newval = oldvalue - argument return newval - if opcode == '*': + if opcode == "*": newval = oldvalue * argument return newval - if opcode == '/': + if opcode == "/": newval = oldvalue / argument return newval - if opcode == '(': - if argument == 'floor': + if opcode == "(": + if argument == "floor": newval = math.floor(oldvalue) else: - logger.error('Unrecognized function named {} in {}= for ' - '{}, returning None'.format( - argument, opcode, error_text)) + logger.error( + "Unrecognized function named {} in {}= for {}, returning None".format(argument, opcode, error_text) + ) return None # If reached here, we do not recognize opcode - logger.error('Unrecognized operation {}= in {}, ' - 'returning None'.format( opcode, error_text)) - except Exception as xcept: - logger.warn("Error performing operator {op}= on oldvalue='{old}' " - "and argument={arg} in {errtext}".format( - op=opcode, old=oldvalue, arg=argument, errtext=error_text)) + logger.error("Unrecognized operation {}= in {}, returning None".format(opcode, error_text)) + except Exception: + logger.warning( + "Error performing operator {op}= on oldvalue='{old}' and argument={arg} in {errtext}".format( + op=opcode, old=oldvalue, arg=argument, errtext=error_text + ) + ) return None def process_attribute_parameter_string( - parameter_string, attribute_name, attribute_parameter_dict = {}, - resources = [], allocations = []): + parameter_string, attribute_name, attribute_parameter_dict={}, resources=[], allocations=[] +): """Processes a single attribute parameter definition/statement. This is called by make_attribute_parameter_dictionary, and handles @@ -295,7 +301,7 @@ def process_attribute_parameter_string( AllocationAttribute or ResourceAttribute (which is then replaced by its (expanded if expandable) value). - See the methods get_attribute_parameter_value() and + See the methods get_attribute_parameter_value() and process_attribute_parameter_operation() for more information about the operations and argument values. """ @@ -305,18 +311,19 @@ def process_attribute_parameter_string( # Ignore comment lines/blank lines (return attribute_parameter_dict) if not parmstr: return attribute_parameter_dict - if parmstr.startswith('#'): + if parmstr.startswith("#"): return attribute_parameter_dict # Parse the parameter string to get pname, op, and argument - tmp = parmstr.split('=', 1) + tmp = parmstr.split("=", 1) if len(tmp) != 2: # No '=' found, so invalid format of parmstr # Log error and return unmodified attribute_parameter_dict - logger.error("Invalid parameter string '{pstr}', no '=', while " + logger.error( + "Invalid parameter string '{pstr}', no '=', while " "creating attribute parameter dictionary for expanding " - "attribute {aname}".format( - aname=attribute_name, pstr=parameter_string)) + "attribute {aname}".format(aname=attribute_name, pstr=parameter_string) + ) return attribute_parameter_dict pname = tmp[0] argument = tmp[1].strip() @@ -327,19 +334,20 @@ def process_attribute_parameter_string( # Argument is a parameter/attribute/constant unless opcode is '(' # So get its value if parameter/attribute/constant value = None - if opcode == '(': + if opcode == "(": value = argument else: # Extra text to display in diagnostics if error occurs - error_text = 'processing attribute_parameter_string={pstr} ' \ - 'for expansion of attribute {aname}'.format( - pstr = parameter_string, aname=attribute_name) + error_text = "processing attribute_parameter_string={pstr} for expansion of attribute {aname}".format( + pstr=parameter_string, aname=attribute_name + ) value = get_attribute_parameter_value( - argument = argument, - attribute_parameter_dict = attribute_parameter_dict, - resources = resources, - allocations = allocations, - error_text = error_text) + argument=argument, + attribute_parameter_dict=attribute_parameter_dict, + resources=resources, + allocations=allocations, + error_text=error_text, + ) # Get the old value of the parameter if pname in attribute_parameter_dict: @@ -349,27 +357,26 @@ def process_attribute_parameter_string( # Perform the requested operation newval = process_attribute_parameter_operation( - opcode=opcode, oldvalue=oldval, argument=value, - error_text=error_text) + opcode=opcode, oldvalue=oldval, argument=value, error_text=error_text + ) # Set value in dictionary and return attribute_parameter_dict[pname] = newval return attribute_parameter_dict -def make_attribute_parameter_dictionary(attribute_name, - attribute_parameter_string, resources=[], allocations=[]): +def make_attribute_parameter_dictionary(attribute_name, attribute_parameter_string, resources=[], allocations=[]): """Create the attribute parameter dictionary. Used by expand_attribute. This processes the given attribute parameter string to generate a - dictionary that will (in expand_attribute()) be passed as the argument - to the standard python format() method acting on the raw value of the + dictionary that will (in expand_attribute()) be passed as the argument + to the standard python format() method acting on the raw value of the attribute to expand it. The attribute parameter string is a string consisting of one or more attribute parameter definitions, one per line, with the following general format: ' = ' - + This routine processes the attribute_parameter_string line by line, in order top to bottom, to generate the dictionary that is returned. @@ -381,28 +388,27 @@ def make_attribute_parameter_dictionary(attribute_name, apdict = dict() # Covert attribute_parameter_string to a real list - attrib_parm_list = list(map(str.strip, - attribute_parameter_string.splitlines() )) + attrib_parm_list = list(map(str.strip, attribute_parameter_string.splitlines())) # Process each element in the list for parmstr in attrib_parm_list: apdict = process_attribute_parameter_string( - parameter_string = parmstr, - attribute_parameter_dict = apdict, - attribute_name = attribute_name, - resources = resources, - allocations = allocations) + parameter_string=parmstr, + attribute_parameter_dict=apdict, + attribute_name=attribute_name, + resources=resources, + allocations=allocations, + ) return apdict -def expand_attribute(raw_value, attribute_name, attriblist_string, - resources = [], allocations = []): +def expand_attribute(raw_value, attribute_name, attriblist_string, resources=[], allocations=[]): """Main method to expand parameters in an attribute. - This takes the (raw) value raw_value of either an AllocationAttribute - or ResourceAttribute, which should be in a python formatted string - (f-string) format; i.e. a string with places where parameter - replacement is desired to have the name of the desired replacement - parameter enclosed in curly braces ('{' and '}'). The parameter name + This takes the (raw) value raw_value of either an AllocationAttribute + or ResourceAttribute, which should be in a python formatted string + (f-string) format; i.e. a string with places where parameter + replacement is desired to have the name of the desired replacement + parameter enclosed in curly braces ('{' and '}'). The parameter name can be followed by standard format() format specifiers, as per standard format() rules. The argument attribute_name should have the name of this attribute, for use in diagnostic messages. @@ -443,10 +449,11 @@ def expand_attribute(raw_value, attribute_name, attriblist_string, try: # Create the attribute parameter dictionary apdict = make_attribute_parameter_dictionary( - attribute_parameter_string = attriblist_string, - attribute_name = attribute_name, - resources = resources, - allocations = allocations) + attribute_parameter_string=attriblist_string, + attribute_name=attribute_name, + resources=resources, + allocations=allocations, + ) # Expand the attribute expanded = raw_value.format(**apdict) @@ -456,17 +463,16 @@ def expand_attribute(raw_value, attribute_name, attriblist_string, # referencing a parameter not defined in apdict to divide by # zero errors in processing apdict. We just log it and then # return raw_value - logger.error("Error expanding {aname}: {error}".format( - aname=attribute_name, error=xcept)) + logger.error("Error expanding {aname}: {error}".format(aname=attribute_name, error=xcept)) return raw_value -def convert_type(value, type_name, error_text='unknown'): +def convert_type(value, type_name, error_text="unknown"): """This returns value with a python type corresponding to type_name. Value is the value to operate on. Type_name is the name of the underlying attribute type (AttributeType), - e.g. Text, Float, Int, Date, etc. + e.g. Text, Float, Int, Date, etc. If type_name ends in Int, we try to return value as a python int. If type_name ends in Float, we try to return value as a python float. @@ -479,38 +485,32 @@ def convert_type(value, type_name, error_text='unknown'): future "Attribute Expanded ..." types. """ if type_name is None: - logger.error('No AttributeType found for {}'.format(error_text)) + logger.error("No AttributeType found for {}".format(error_text)) return value - if type_name.endswith('Text'): + if type_name.endswith("Text"): try: newval = str(value) return newval except ValueError: - logger.error('Error converting "{}" to {} in {}'.format( - value, 'Text', error_text)) + logger.error('Error converting "{}" to {} in {}'.format(value, "Text", error_text)) return value - if type_name.endswith('Int'): + if type_name.endswith("Int"): try: newval = int(value) return newval except ValueError: - logger.error('Error converting "{}" to {} in {}'.format( - value, 'Int', error_text)) + logger.error('Error converting "{}" to {} in {}'.format(value, "Int", error_text)) return value - if type_name.endswith('Float'): + if type_name.endswith("Float"): try: newval = float(value) return newval except ValueError: - logger.error('Error converting "{}" to {} in {}'.format( - value, 'Float', error_text)) + logger.error('Error converting "{}" to {} in {}'.format(value, "Float", error_text)) return value - #If not any of the above, just return the value (probably a string) + # If not any of the above, just return the value (probably a string) return value - - - diff --git a/coldfront/core/field_of_science/__init__.py b/coldfront/core/field_of_science/__init__.py index 7ef20f9442..6d24412f63 100644 --- a/coldfront/core/field_of_science/__init__.py +++ b/coldfront/core/field_of_science/__init__.py @@ -1 +1,4 @@ -default_app_config = 'coldfront.core.field_of_science.apps.FieldOfScienceConfig' +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + diff --git a/coldfront/core/field_of_science/admin.py b/coldfront/core/field_of_science/admin.py index be099919c4..44d527b946 100644 --- a/coldfront/core/field_of_science/admin.py +++ b/coldfront/core/field_of_science/admin.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from coldfront.core.field_of_science.models import FieldOfScience @@ -6,12 +10,12 @@ @admin.register(FieldOfScience) class FieldOfScienceAdmin(admin.ModelAdmin): list_display = ( - 'description', - 'is_selectable', - 'parent_id', - 'fos_nsf_id', - 'fos_nsf_abbrev', - 'directorate_fos_id', + "description", + "is_selectable", + "parent_id", + "fos_nsf_id", + "fos_nsf_abbrev", + "directorate_fos_id", ) - list_filter = ('is_selectable', ) - search_fields = ['description'] + list_filter = ("is_selectable",) + search_fields = ["description"] diff --git a/coldfront/core/field_of_science/apps.py b/coldfront/core/field_of_science/apps.py index 55da13d858..b7cbc7fb0c 100644 --- a/coldfront/core/field_of_science/apps.py +++ b/coldfront/core/field_of_science/apps.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class FieldOfScienceConfig(AppConfig): - name = 'coldfront.core.field_of_science' - verbose_name = 'Field of Science' + name = "coldfront.core.field_of_science" + verbose_name = "Field of Science" diff --git a/coldfront/core/field_of_science/management/__init__.py b/coldfront/core/field_of_science/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/field_of_science/management/__init__.py +++ b/coldfront/core/field_of_science/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/field_of_science/management/commands/__init__.py b/coldfront/core/field_of_science/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/field_of_science/management/commands/__init__.py +++ b/coldfront/core/field_of_science/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py b/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py index 4d47eef6bc..f71e1f0038 100644 --- a/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py +++ b/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import os -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from coldfront.core.field_of_science.models import FieldOfScience @@ -8,15 +12,17 @@ class Command(BaseCommand): - help = 'Import field of science data' + help = "Import field of science data" def handle(self, *args, **options): - print('Adding field of science ...') - file_path = os.path.join(app_commands_dir, 'data', 'field_of_science_data.csv') + self.stdout.write("Adding field of science ...") + file_path = os.path.join(app_commands_dir, "data", "field_of_science_data.csv") FieldOfScience.objects.all().delete() - with open(file_path, 'r') as fp: + with open(file_path, "r") as fp: for line in fp: - pk, parent_id, is_selectable, description, fos_nsf_id, fos_nsf_abbrev, directorate_fos_id = line.strip().split('\t') + pk, parent_id, is_selectable, description, fos_nsf_id, fos_nsf_abbrev, directorate_fos_id = ( + line.strip().split("\t") + ) fos = FieldOfScience( pk=pk, @@ -24,12 +30,12 @@ def handle(self, *args, **options): description=description, fos_nsf_id=fos_nsf_id, fos_nsf_abbrev=fos_nsf_abbrev, - directorate_fos_id=directorate_fos_id + directorate_fos_id=directorate_fos_id, ) fos.save() - if parent_id != 'self': + if parent_id != "self": parent_fos = FieldOfScience.objects.get(id=parent_id) - fos.parent_id=parent_fos + fos.parent_id = parent_fos fos.save() - print('Finished adding field of science') + self.stdout.write("Finished adding field of science") diff --git a/coldfront/core/field_of_science/migrations/0001_initial.py b/coldfront/core/field_of_science/migrations/0001_initial.py index a17e77b1ec..cdaa0ff0d1 100644 --- a/coldfront/core/field_of_science/migrations/0001_initial.py +++ b/coldfront/core/field_of_science/migrations/0001_initial.py @@ -1,34 +1,51 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields +from django.db import migrations, models class Migration(migrations.Migration): - initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='FieldOfScience', + name="FieldOfScience", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('is_selectable', models.BooleanField(default=True)), - ('description', models.CharField(max_length=255)), - ('fos_nsf_id', models.IntegerField(blank=True, null=True)), - ('fos_nsf_abbrev', models.CharField(blank=True, max_length=10, null=True)), - ('directorate_fos_id', models.IntegerField(blank=True, null=True)), - ('parent_id', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='field_of_science.FieldOfScience')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("is_selectable", models.BooleanField(default=True)), + ("description", models.CharField(max_length=255)), + ("fos_nsf_id", models.IntegerField(blank=True, null=True)), + ("fos_nsf_abbrev", models.CharField(blank=True, max_length=10, null=True)), + ("directorate_fos_id", models.IntegerField(blank=True, null=True)), + ( + "parent_id", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to="field_of_science.FieldOfScience" + ), + ), ], options={ - 'ordering': ['description'], + "ordering": ["description"], }, ), ] diff --git a/coldfront/core/field_of_science/migrations/0002_alter_fieldofscience_description.py b/coldfront/core/field_of_science/migrations/0002_alter_fieldofscience_description.py index 40813f6d90..36b65f12c6 100644 --- a/coldfront/core/field_of_science/migrations/0002_alter_fieldofscience_description.py +++ b/coldfront/core/field_of_science/migrations/0002_alter_fieldofscience_description.py @@ -1,18 +1,21 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 3.2.17 on 2023-04-06 15:33 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('field_of_science', '0001_initial'), + ("field_of_science", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='fieldofscience', - name='description', + model_name="fieldofscience", + name="description", field=models.CharField(max_length=255, unique=True), ), ] diff --git a/coldfront/core/field_of_science/migrations/__init__.py b/coldfront/core/field_of_science/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/field_of_science/migrations/__init__.py +++ b/coldfront/core/field_of_science/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/field_of_science/models.py b/coldfront/core/field_of_science/models.py index 89108a40c7..60e5de5265 100644 --- a/coldfront/core/field_of_science/models.py +++ b/coldfront/core/field_of_science/models.py @@ -1,9 +1,14 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.db import models from model_utils.models import TimeStampedModel + class FieldOfScience(TimeStampedModel): - """ A field of science is a division under which a project falls. The list is prepopulated in ColdFront using the National Science Foundation FOS list, but can be changed by a center admin if needed. Examples include Chemistry and Physics. - + """A field of science is a division under which a project falls. The list is prepopulated in ColdFront using the National Science Foundation FOS list, but can be changed by a center admin if needed. Examples include Chemistry and Physics. + Attributes: parent_id (FieldOfScience): represents parent field of science if it exists is_selectable (bool): indicates whether or not a field of science is selectable for a project @@ -12,15 +17,16 @@ class FieldOfScience(TimeStampedModel): fos_nsf_abbrev (str): represents the field of science's abbreviation under the National Science Foundation directorate_fos_id (int): represents the National Science Foundation's ID for the department the field of science falls under """ + class Meta: - ordering = ['description'] + ordering = ["description"] class FieldOfScienceManager(models.Manager): def get_by_natural_key(self, description): return self.get(description=description) DEFAULT_PK = 149 - parent_id = models.ForeignKey('self', on_delete=models.CASCADE, null=True) + parent_id = models.ForeignKey("self", on_delete=models.CASCADE, null=True) is_selectable = models.BooleanField(default=True) description = models.CharField(max_length=255, unique=True) fos_nsf_id = models.IntegerField(null=True, blank=True) diff --git a/coldfront/core/field_of_science/tests/__init__.py b/coldfront/core/field_of_science/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/field_of_science/tests.py b/coldfront/core/field_of_science/tests/tests.py similarity index 83% rename from coldfront/core/field_of_science/tests.py rename to coldfront/core/field_of_science/tests/tests.py index 9c2e460dd8..bcde4c4a4c 100644 --- a/coldfront/core/field_of_science/tests.py +++ b/coldfront/core/field_of_science/tests/tests.py @@ -1,27 +1,31 @@ -from coldfront.core.test_helpers.factories import FieldOfScienceFactory +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.exceptions import ValidationError from django.test import TestCase from coldfront.core.field_of_science.models import FieldOfScience +from coldfront.core.test_helpers.factories import FieldOfScienceFactory + class TestFieldOfScience(TestCase): class Data: """Collection of test data, separated for readability""" def __init__(self): - self.initial_fields = { - 'pk': 11, - 'parent_id': FieldOfScienceFactory(), - 'is_selectable': False, - 'description': 'Astronomical Sciences', - 'fos_nsf_id': 120, - 'fos_nsf_abbrev': 'AST', - 'directorate_fos_id': 1 + "pk": 11, + "parent_id": FieldOfScienceFactory(), + "is_selectable": False, + "description": "Astronomical Sciences", + "fos_nsf_id": 120, + "fos_nsf_abbrev": "AST", + "directorate_fos_id": 1, } - + self.unsaved_object = FieldOfScience(**self.initial_fields) - + def setUp(self): self.data = self.Data() @@ -58,13 +62,13 @@ def test_nsf_abbrev_optional(self): self.assertEqual(1, len(FieldOfScience.objects.all())) fos_obj = self.data.unsaved_object - fos_obj.fos_nsf_abbrev = '' + fos_obj.fos_nsf_abbrev = "" fos_obj.save() self.assertEqual(2, len(FieldOfScience.objects.all())) retrieved_obj = FieldOfScience.objects.get(pk=fos_obj.pk) - self.assertEqual('', retrieved_obj.fos_nsf_abbrev) + self.assertEqual("", retrieved_obj.fos_nsf_abbrev) def test_directorate_fos_id_optional(self): self.assertEqual(1, len(FieldOfScience.objects.all())) @@ -80,11 +84,11 @@ def test_directorate_fos_id_optional(self): def test_description_maxlength(self): expected_maximum_length = 255 - maximum_description = 'x' * expected_maximum_length + maximum_description = "x" * expected_maximum_length fos_obj = self.data.unsaved_object - fos_obj.description = maximum_description + 'x' + fos_obj.description = maximum_description + "x" with self.assertRaises(ValidationError): fos_obj.clean_fields() @@ -97,11 +101,11 @@ def test_description_maxlength(self): def test_nsf_abbrev_maxlength(self): expected_maximum_length = 10 - maximum_nsf_abbrev = 'x' * expected_maximum_length + maximum_nsf_abbrev = "x" * expected_maximum_length fos_obj = self.data.unsaved_object - fos_obj.fos_nsf_abbrev = maximum_nsf_abbrev + 'x' + fos_obj.fos_nsf_abbrev = maximum_nsf_abbrev + "x" with self.assertRaises(ValidationError): fos_obj.clean_fields() diff --git a/coldfront/core/field_of_science/views.py b/coldfront/core/field_of_science/views.py index 91ea44a218..2fa8704650 100644 --- a/coldfront/core/field_of_science/views.py +++ b/coldfront/core/field_of_science/views.py @@ -1,3 +1,5 @@ -from django.shortcuts import render +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your views here. diff --git a/coldfront/core/grant/__init__.py b/coldfront/core/grant/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/grant/__init__.py +++ b/coldfront/core/grant/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/grant/admin.py b/coldfront/core/grant/admin.py index ef8add720e..b77b71f36c 100644 --- a/coldfront/core/grant/admin.py +++ b/coldfront/core/grant/admin.py @@ -1,33 +1,67 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin from coldfront.core.grant.models import Grant, GrantFundingAgency -from simple_history.admin import SimpleHistoryAdmin @admin.register(GrantFundingAgency) class GrantFundingAgencyChoiceAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ("name",) @admin.register(Grant) class GrantAdmin(SimpleHistoryAdmin): - readonly_fields = ('project', 'created', 'modified',) - fields = ('project', 'title', 'grant_number', 'role', 'grant_pi_full_name', 'funding_agency', 'other_funding_agency', 'other_award_number', 'grant_start', - 'grant_end', 'percent_credit', 'direct_funding', 'total_amount_awarded', 'status', 'created', 'modified') - list_display = ['title', 'Project_PI', 'role', - 'grant_pi_full_name', 'Funding_Agency', 'status', 'grant_end', ] - list_filter = ('funding_agency', 'role', 'status', 'grant_end') - search_fields = ['project__title', - 'project__pi__username', - 'project__pi__first_name', - 'project__pi__last_name', - 'funding_agency__name', 'grant_pi_full_name'] + readonly_fields = ( + "project", + "created", + "modified", + ) + fields = ( + "project", + "title", + "grant_number", + "role", + "grant_pi_full_name", + "funding_agency", + "other_funding_agency", + "other_award_number", + "grant_start", + "grant_end", + "percent_credit", + "direct_funding", + "total_amount_awarded", + "status", + "created", + "modified", + ) + list_display = [ + "title", + "Project_PI", + "role", + "grant_pi_full_name", + "Funding_Agency", + "status", + "grant_end", + ] + list_filter = ("funding_agency", "role", "status", "grant_end") + search_fields = [ + "project__title", + "project__pi__username", + "project__pi__first_name", + "project__pi__last_name", + "funding_agency__name", + "grant_pi_full_name", + ] def Project_PI(self, obj): - return '{} {} ({})'.format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) + return "{} {} ({})".format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) def Funding_Agency(self, obj): - if obj.funding_agency.name == 'Other': + if obj.funding_agency.name == "Other": return obj.other_funding_agency else: return obj.funding_agency.name diff --git a/coldfront/core/grant/apps.py b/coldfront/core/grant/apps.py index 6e10b638a0..63cd903726 100644 --- a/coldfront/core/grant/apps.py +++ b/coldfront/core/grant/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class GrantConfig(AppConfig): - name = 'coldfront.core.grant' + name = "coldfront.core.grant" diff --git a/coldfront/core/grant/forms.py b/coldfront/core/grant/forms.py index 0a6f5c8909..b15cc083a7 100644 --- a/coldfront/core/grant/forms.py +++ b/coldfront/core/grant/forms.py @@ -1,38 +1,48 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import forms from django.forms import ModelForm -from django.shortcuts import get_object_or_404 -from coldfront.core.grant.models import Grant, MoneyField +from coldfront.core.grant.models import Grant from coldfront.core.utils.common import import_from_settings -CENTER_NAME = import_from_settings('CENTER_NAME') +CENTER_NAME = import_from_settings("CENTER_NAME") class GrantForm(ModelForm): class Meta: model = Grant - exclude = ['project', ] + exclude = [ + "project", + ] labels = { - 'percent_credit': 'Percent credit to {}'.format(CENTER_NAME), - 'direct_funding': 'Direct funding to {}'.format(CENTER_NAME) + "percent_credit": "Percent credit to {}".format(CENTER_NAME), + "direct_funding": "Direct funding to {}".format(CENTER_NAME), } help_texts = { - 'percent_credit': 'Percent credit as entered in the sponsored projects form for grant submission as financial credit to the department/unit in the credit distribution section. Enter only digits, decimals, percent symbols, or spaces.', - 'direct_funding': 'Funds budgeted specifically for {} services, hardware, software, and/or personnel. Enter only digits, decimals, commas, dollar signs, or spaces.'.format(CENTER_NAME), - 'total_amount_awarded': 'Enter only digits, decimals, commas, dollar signs, or spaces.' + "percent_credit": "Percent credit as entered in the sponsored projects form for grant submission as financial credit to the department/unit in the credit distribution section. Enter only digits, decimals, percent symbols, or spaces.", + "direct_funding": "Funds budgeted specifically for {} services, hardware, software, and/or personnel. Enter only digits, decimals, commas, dollar signs, or spaces.".format( + CENTER_NAME + ), + "total_amount_awarded": "Enter only digits, decimals, commas, dollar signs, or spaces.", } def __init__(self, *args, **kwargs): - super(GrantForm, self).__init__(*args, **kwargs) - self.fields['funding_agency'].queryset = self.fields['funding_agency'].queryset.order_by('name') + super(GrantForm, self).__init__(*args, **kwargs) + self.fields["funding_agency"].queryset = self.fields["funding_agency"].queryset.order_by("name") + self.fields["grant_start"].widget.attrs["class"] = "datepicker" + self.fields["grant_end"].widget.attrs["class"] = "datepicker" + class GrantDeleteForm(forms.Form): title = forms.CharField(max_length=255, disabled=True) - grant_number = forms.CharField( - max_length=30, required=False, disabled=True) + grant_number = forms.CharField(max_length=30, required=False, disabled=True) grant_end = forms.CharField(max_length=150, required=False, disabled=True) selected = forms.BooleanField(initial=False, required=False) + class GrantDownloadForm(forms.Form): pk = forms.IntegerField(required=False, disabled=True) title = forms.CharField(required=False, disabled=True) @@ -52,4 +62,4 @@ class GrantDownloadForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() diff --git a/coldfront/core/grant/management/__init__.py b/coldfront/core/grant/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/grant/management/__init__.py +++ b/coldfront/core/grant/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/grant/management/commands/__init__.py b/coldfront/core/grant/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/grant/management/commands/__init__.py +++ b/coldfront/core/grant/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/grant/management/commands/add_default_grant_options.py b/coldfront/core/grant/management/commands/add_default_grant_options.py index 4c7424abfc..e5d93ee71b 100644 --- a/coldfront/core/grant/management/commands/add_default_grant_options.py +++ b/coldfront/core/grant/management/commands/add_default_grant_options.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import os from django.core.management.base import BaseCommand @@ -9,23 +13,26 @@ class Command(BaseCommand): def handle(self, *args, **options): - GrantFundingAgency.objects.all().delete() for choice in [ - 'Department of Defense (DoD)', - 'Department of Energy (DOE)', - 'Environmental Protection Agency (EPA)', - 'National Aeronautics and Space Administration (NASA)', - 'National Institutes of Health (NIH)', - 'National Science Foundation (NSF)', - 'New York State Department of Health (DOH)', - 'New York State (NYS)', - 'Empire State Development (ESD)', - "Empire State Development's Division of Science, Technology and Innovation (NYSTAR)", - 'Other' - ]: + "Department of Defense (DoD)", + "Department of Energy (DOE)", + "Environmental Protection Agency (EPA)", + "National Aeronautics and Space Administration (NASA)", + "National Institutes of Health (NIH)", + "National Science Foundation (NSF)", + "New York State Department of Health (DOH)", + "New York State (NYS)", + "Empire State Development (ESD)", + "Empire State Development's Division of Science, Technology and Innovation (NYSTAR)", + "Other", + ]: GrantFundingAgency.objects.get_or_create(name=choice) GrantStatusChoice.objects.all().delete() - for choice in ['Active', 'Archived', 'Pending', ]: + for choice in [ + "Active", + "Archived", + "Pending", + ]: GrantStatusChoice.objects.get_or_create(name=choice) diff --git a/coldfront/core/grant/migrations/0001_initial.py b/coldfront/core/grant/migrations/0001_initial.py index c3ce0f3658..c8c5068cb2 100644 --- a/coldfront/core/grant/migrations/0001_initial.py +++ b/coldfront/core/grant/migrations/0001_initial.py @@ -1,105 +1,253 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('project', '0001_initial'), + ("project", "0001_initial"), ] operations = [ migrations.CreateModel( - name='GrantFundingAgency', + name="GrantFundingAgency", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=255)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=255)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='GrantStatusChoice', + name="GrantStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ('name',), + "ordering": ("name",), }, ), migrations.CreateModel( - name='HistoricalGrant', + name="HistoricalGrant", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=255, validators=[django.core.validators.MinLengthValidator(3), django.core.validators.MaxLengthValidator(255)])), - ('grant_number', models.CharField(max_length=255, validators=[django.core.validators.MinLengthValidator(3), django.core.validators.MaxLengthValidator(255)], verbose_name='Grant Number from funding agency')), - ('role', models.CharField(choices=[('PI', 'Principal Investigator (PI)'), ('CoPI', 'Co-Principal Investigator (CoPI)'), ('SP', 'Senior Personnel (SP)')], max_length=10)), - ('grant_pi_full_name', models.CharField(blank=True, max_length=255, verbose_name='Grant PI Full Name')), - ('other_funding_agency', models.CharField(blank=True, max_length=255)), - ('other_award_number', models.CharField(blank=True, max_length=255)), - ('grant_start', models.DateField(verbose_name='Grant Start Date')), - ('grant_end', models.DateField(verbose_name='Grant End Date')), - ('percent_credit', models.FloatField(validators=[django.core.validators.MaxValueValidator(100)])), - ('direct_funding', models.FloatField()), - ('total_amount_awarded', models.FloatField()), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('funding_agency', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='grant.GrantFundingAgency')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='grant.GrantStatusChoice')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "title", + models.CharField( + max_length=255, + validators=[ + django.core.validators.MinLengthValidator(3), + django.core.validators.MaxLengthValidator(255), + ], + ), + ), + ( + "grant_number", + models.CharField( + max_length=255, + validators=[ + django.core.validators.MinLengthValidator(3), + django.core.validators.MaxLengthValidator(255), + ], + verbose_name="Grant Number from funding agency", + ), + ), + ( + "role", + models.CharField( + choices=[ + ("PI", "Principal Investigator (PI)"), + ("CoPI", "Co-Principal Investigator (CoPI)"), + ("SP", "Senior Personnel (SP)"), + ], + max_length=10, + ), + ), + ("grant_pi_full_name", models.CharField(blank=True, max_length=255, verbose_name="Grant PI Full Name")), + ("other_funding_agency", models.CharField(blank=True, max_length=255)), + ("other_award_number", models.CharField(blank=True, max_length=255)), + ("grant_start", models.DateField(verbose_name="Grant Start Date")), + ("grant_end", models.DateField(verbose_name="Grant End Date")), + ("percent_credit", models.FloatField(validators=[django.core.validators.MaxValueValidator(100)])), + ("direct_funding", models.FloatField()), + ("total_amount_awarded", models.FloatField()), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "funding_agency", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="grant.GrantFundingAgency", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="grant.GrantStatusChoice", + ), + ), ], options={ - 'verbose_name': 'historical grant', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical grant", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='Grant', + name="Grant", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=255, validators=[django.core.validators.MinLengthValidator(3), django.core.validators.MaxLengthValidator(255)])), - ('grant_number', models.CharField(max_length=255, validators=[django.core.validators.MinLengthValidator(3), django.core.validators.MaxLengthValidator(255)], verbose_name='Grant Number from funding agency')), - ('role', models.CharField(choices=[('PI', 'Principal Investigator (PI)'), ('CoPI', 'Co-Principal Investigator (CoPI)'), ('SP', 'Senior Personnel (SP)')], max_length=10)), - ('grant_pi_full_name', models.CharField(blank=True, max_length=255, verbose_name='Grant PI Full Name')), - ('other_funding_agency', models.CharField(blank=True, max_length=255)), - ('other_award_number', models.CharField(blank=True, max_length=255)), - ('grant_start', models.DateField(verbose_name='Grant Start Date')), - ('grant_end', models.DateField(verbose_name='Grant End Date')), - ('percent_credit', models.FloatField(validators=[django.core.validators.MaxValueValidator(100)])), - ('direct_funding', models.FloatField()), - ('total_amount_awarded', models.FloatField()), - ('funding_agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='grant.GrantFundingAgency')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), - ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='grant.GrantStatusChoice')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "title", + models.CharField( + max_length=255, + validators=[ + django.core.validators.MinLengthValidator(3), + django.core.validators.MaxLengthValidator(255), + ], + ), + ), + ( + "grant_number", + models.CharField( + max_length=255, + validators=[ + django.core.validators.MinLengthValidator(3), + django.core.validators.MaxLengthValidator(255), + ], + verbose_name="Grant Number from funding agency", + ), + ), + ( + "role", + models.CharField( + choices=[ + ("PI", "Principal Investigator (PI)"), + ("CoPI", "Co-Principal Investigator (CoPI)"), + ("SP", "Senior Personnel (SP)"), + ], + max_length=10, + ), + ), + ("grant_pi_full_name", models.CharField(blank=True, max_length=255, verbose_name="Grant PI Full Name")), + ("other_funding_agency", models.CharField(blank=True, max_length=255)), + ("other_award_number", models.CharField(blank=True, max_length=255)), + ("grant_start", models.DateField(verbose_name="Grant Start Date")), + ("grant_end", models.DateField(verbose_name="Grant End Date")), + ("percent_credit", models.FloatField(validators=[django.core.validators.MaxValueValidator(100)])), + ("direct_funding", models.FloatField()), + ("total_amount_awarded", models.FloatField()), + ( + "funding_agency", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="grant.GrantFundingAgency"), + ), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), + ( + "status", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="grant.GrantStatusChoice"), + ), ], options={ - 'verbose_name_plural': 'Grants', - 'permissions': (('can_view_all_grants', 'Can view all grants'),), + "verbose_name_plural": "Grants", + "permissions": (("can_view_all_grants", "Can view all grants"),), }, ), ] diff --git a/coldfront/core/grant/migrations/0002_auto_20230406_1310.py b/coldfront/core/grant/migrations/0002_auto_20230406_1310.py index 921e6b9814..edcab61213 100644 --- a/coldfront/core/grant/migrations/0002_auto_20230406_1310.py +++ b/coldfront/core/grant/migrations/0002_auto_20230406_1310.py @@ -1,23 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 3.2.17 on 2023-04-06 17:10 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('grant', '0001_initial'), + ("grant", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='grantfundingagency', - name='name', + model_name="grantfundingagency", + name="name", field=models.CharField(max_length=255, unique=True), ), migrations.AlterField( - model_name='grantstatuschoice', - name='name', + model_name="grantstatuschoice", + name="name", field=models.CharField(max_length=64, unique=True), ), ] diff --git a/coldfront/core/grant/migrations/0003_alter_historicalgrant_options_and_more.py b/coldfront/core/grant/migrations/0003_alter_historicalgrant_options_and_more.py new file mode 100644 index 0000000000..d18d98e1ed --- /dev/null +++ b/coldfront/core/grant/migrations/0003_alter_historicalgrant_options_and_more.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Generated by Django 4.2.23 on 2025-10-17 14:54 + +import django.core.validators +from django.db import migrations, models + +import coldfront.core.grant.models + + +class Migration(migrations.Migration): + dependencies = [ + ("grant", "0002_auto_20230406_1310"), + ] + + operations = [ + migrations.AlterModelOptions( + name="historicalgrant", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical grant", + "verbose_name_plural": "historical Grants", + }, + ), + migrations.AlterField( + model_name="grant", + name="direct_funding", + field=coldfront.core.grant.models.MoneyField(max_length=100), + ), + migrations.AlterField( + model_name="grant", + name="percent_credit", + field=coldfront.core.grant.models.PercentField( + max_length=100, validators=[django.core.validators.MaxValueValidator(100)] + ), + ), + migrations.AlterField( + model_name="grant", + name="total_amount_awarded", + field=coldfront.core.grant.models.MoneyField(max_length=100), + ), + migrations.AlterField( + model_name="historicalgrant", + name="direct_funding", + field=coldfront.core.grant.models.MoneyField(max_length=100), + ), + migrations.AlterField( + model_name="historicalgrant", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalgrant", + name="percent_credit", + field=coldfront.core.grant.models.PercentField( + max_length=100, validators=[django.core.validators.MaxValueValidator(100)] + ), + ), + migrations.AlterField( + model_name="historicalgrant", + name="total_amount_awarded", + field=coldfront.core.grant.models.MoneyField(max_length=100), + ), + ] diff --git a/coldfront/core/grant/migrations/__init__.py b/coldfront/core/grant/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/grant/migrations/__init__.py +++ b/coldfront/core/grant/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/grant/models.py b/coldfront/core/grant/models.py index af60ecb131..d1fa1b28f4 100644 --- a/coldfront/core/grant/models.py +++ b/coldfront/core/grant/models.py @@ -1,15 +1,19 @@ -from django.core.validators import (MaxLengthValidator, MaxValueValidator, - MinLengthValidator) +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.core.exceptions import ValidationError +from django.core.validators import MaxLengthValidator, MaxValueValidator, MinLengthValidator, RegexValidator from django.db import models from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords -from django.core.validators import RegexValidator from coldfront.core.project.models import Project + class GrantFundingAgency(TimeStampedModel): - """ A grant funding agency is an agency that funds projects. Examples include Department of Defense (DoD) and National Aeronautics and Space Administration (NASA). - + """A grant funding agency is an agency that funds projects. Examples include Department of Defense (DoD) and National Aeronautics and Space Administration (NASA). + Attributes: name (str): agency name """ @@ -27,14 +31,16 @@ def __str__(self): def natural_key(self): return [self.name] + class GrantStatusChoice(TimeStampedModel): - """ A grant status choice is an option a user has when setting the status of a grant. Examples include Active, Archived, and Pending. - + """A grant status choice is an option a user has when setting the status of a grant. Examples include Active, Archived, and Pending. + Attributes: name (str): status name """ + class Meta: - ordering = ('name',) + ordering = ("name",) class GrantStatusManager(models.Manager): def get_by_natural_key(self, name): @@ -48,13 +54,15 @@ def __str__(self): def natural_key(self): return [self.name] - + + class MoneyField(models.CharField): validators = [ - RegexValidator(r'\$*[\d,.]{1,}$', - 'Enter only digits, decimals, commas, dollar signs, or spaces.', - 'Invalid input.') + RegexValidator( + r"\$*[\d,.]{1,}$", "Enter only digits, decimals, commas, dollar signs, or spaces.", "Invalid input." + ) ] + def to_python(self, value): value = super().to_python(value) if value: @@ -62,23 +70,31 @@ def to_python(self, value): value = value.replace(",", "") value = value.replace("$", "") return value - + + class PercentField(models.CharField): validators = [ - RegexValidator(r'^[\d,.]{1,6}\%*$', - 'Enter only digits, decimals, percent symbols, or spaces.', - 'Invalid input.') + RegexValidator( + r"^[\d,.]{1,6}\%*$", "Enter only digits, decimals, percent symbols, or spaces.", "Invalid input." + ) ] + def to_python(self, value): value = super().to_python(value) if value: value = value.replace(" ", "") value = value.replace("%", "") + try: + if float(value) > 100: + raise ValidationError("Percent credit should be less than 100") + except ValueError: + pass return value + class Grant(TimeStampedModel): - """ A grant is funding that a PI receives for their project. - + """A grant is funding that a PI receives for their project. + Attributes: project (Project): links the project to the grant title (str): grant title @@ -95,33 +111,33 @@ class Grant(TimeStampedModel): total_amount_awarded (float): indicates the total amount awarded status (GrantStatusChoice): represents the status of the grant """ - + project = models.ForeignKey(Project, on_delete=models.CASCADE) title = models.CharField( validators=[MinLengthValidator(3), MaxLengthValidator(255)], max_length=255, ) grant_number = models.CharField( - 'Grant Number from funding agency', + "Grant Number from funding agency", validators=[MinLengthValidator(3), MaxLengthValidator(255)], max_length=255, ) ROLE_CHOICES = ( - ('PI', 'Principal Investigator (PI)'), - ('CoPI', 'Co-Principal Investigator (CoPI)'), - ('SP', 'Senior Personnel (SP)'), + ("PI", "Principal Investigator (PI)"), + ("CoPI", "Co-Principal Investigator (CoPI)"), + ("SP", "Senior Personnel (SP)"), ) role = models.CharField( max_length=10, choices=ROLE_CHOICES, ) - grant_pi_full_name = models.CharField('Grant PI Full Name', max_length=255, blank=True) + grant_pi_full_name = models.CharField("Grant PI Full Name", max_length=255, blank=True) funding_agency = models.ForeignKey(GrantFundingAgency, on_delete=models.CASCADE) other_funding_agency = models.CharField(max_length=255, blank=True) other_award_number = models.CharField(max_length=255, blank=True) - grant_start = models.DateField('Grant Start Date') - grant_end = models.DateField('Grant End Date') + grant_start = models.DateField("Grant Start Date") + grant_end = models.DateField("Grant End Date") percent_credit = PercentField(max_length=100, validators=[MaxValueValidator(100)]) direct_funding = MoneyField(max_length=100) total_amount_awarded = MoneyField(max_length=100) @@ -130,13 +146,13 @@ class Grant(TimeStampedModel): @property def grant_pi(self): - """ + """ Returns: str: the grant's PI's full name """ - if self.role == 'PI': - return '{} {}'.format(self.project.pi.first_name, self.project.pi.last_name) + if self.role == "PI": + return "{} {}".format(self.project.pi.first_name, self.project.pi.last_name) else: return self.grant_pi_full_name @@ -146,6 +162,4 @@ def __str__(self): class Meta: verbose_name_plural = "Grants" - permissions = ( - ("can_view_all_grants", "Can view all grants"), - ) + permissions = (("can_view_all_grants", "Can view all grants"),) diff --git a/coldfront/core/grant/templates/grant/grant_create.html b/coldfront/core/grant/templates/grant/grant_create.html index c44aa4ba93..c9fe87ab84 100644 --- a/coldfront/core/grant/templates/grant/grant_create.html +++ b/coldfront/core/grant/templates/grant/grant_create.html @@ -1,4 +1,4 @@ -{% extends "common/base.html" %} +{% extends "common/base.html" %} {% load crispy_forms_tags %} {% load static %} @@ -10,17 +10,11 @@ {% block content %} +

Creating grant for project: {{ project.title }}

{% csrf_token %} {{ form|crispy }} Cancel
- - - {% endblock %} - diff --git a/coldfront/core/grant/templates/grant/grant_delete_grants.html b/coldfront/core/grant/templates/grant/grant_delete_grants.html index fc559a6c80..437f76552c 100644 --- a/coldfront/core/grant/templates/grant/grant_delete_grants.html +++ b/coldfront/core/grant/templates/grant/grant_delete_grants.html @@ -22,7 +22,7 @@

Delete grants from project: {{project.title}}

- + Title Grant Number @@ -57,16 +57,4 @@

Delete grants from project: {{project.title}}

{% endif %} - {% endblock %} diff --git a/coldfront/core/grant/templates/grant/grant_report_list.html b/coldfront/core/grant/templates/grant/grant_report_list.html index 2a6f9902c2..6d479fb4d9 100644 --- a/coldfront/core/grant/templates/grant/grant_report_list.html +++ b/coldfront/core/grant/templates/grant/grant_report_list.html @@ -14,17 +14,17 @@

Grants

- +
{% if formset %}
{% csrf_token %}
- +
- + @@ -67,30 +67,4 @@

Grants

- {% endblock %} diff --git a/coldfront/core/grant/templates/grant/grant_update_form.html b/coldfront/core/grant/templates/grant/grant_update_form.html index c3fec5ee4b..7aaadfa3a7 100644 --- a/coldfront/core/grant/templates/grant/grant_update_form.html +++ b/coldfront/core/grant/templates/grant/grant_update_form.html @@ -9,6 +9,7 @@ {% block content %} +

Updating grant for project: {{ project.title }}

{% csrf_token %} {{ form|crispy }} @@ -16,8 +17,4 @@ Cancel - {% endblock %} diff --git a/coldfront/core/grant/tests/__init__.py b/coldfront/core/grant/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/grant/tests.py b/coldfront/core/grant/tests/tests.py similarity index 76% rename from coldfront/core/grant/tests.py rename to coldfront/core/grant/tests/tests.py index 2b7cf7aada..927ba6e39a 100644 --- a/coldfront/core/grant/tests.py +++ b/coldfront/core/grant/tests/tests.py @@ -1,17 +1,20 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime from dateutil.relativedelta import relativedelta - from django.core.exceptions import ValidationError from django.test import TestCase +from coldfront.core.grant.models import Grant from coldfront.core.test_helpers.factories import ( GrantFundingAgencyFactory, GrantStatusChoiceFactory, ProjectFactory, ) -from coldfront.core.grant.models import Grant class TestGrant(TestCase): class Data: @@ -19,30 +22,29 @@ class Data: def __init__(self): project = ProjectFactory() - grantFundingAgency = GrantFundingAgencyFactory(name='Department of Defense (DoD)') - grantStatusChoice = GrantStatusChoiceFactory(name='Active') + grantFundingAgency = GrantFundingAgencyFactory(name="Department of Defense (DoD)") + grantStatusChoice = GrantStatusChoiceFactory(name="Active") start_date = datetime.date.today() end_date = start_date + relativedelta(days=900) - self.initial_fields = { - 'project': project, - 'title':'Quantum Halls', - 'grant_number':'12345', - 'role':'PI', - 'grant_pi_full_name':'Stephanie Foster', - 'funding_agency': grantFundingAgency, - 'grant_start':start_date, - 'grant_end':end_date, - 'percent_credit':20.0, - 'direct_funding':200000.0, - 'total_amount_awarded':1000000.0, - 'status': grantStatusChoice + "project": project, + "title": "Quantum Halls", + "grant_number": "12345", + "role": "PI", + "grant_pi_full_name": "Stephanie Foster", + "funding_agency": grantFundingAgency, + "grant_start": start_date, + "grant_end": end_date, + "percent_credit": "20.0", + "direct_funding": "200000.0", + "total_amount_awarded": "1000000.0", + "status": grantStatusChoice, } - + self.unsaved_object = Grant(**self.initial_fields) - + def setUp(self): self.data = self.Data() @@ -65,7 +67,7 @@ def test_fields_generic(self): def test_title_minlength(self): expected_minimum_length = 3 - minimum_title = 'x' * expected_minimum_length + minimum_title = "x" * expected_minimum_length grant_obj = self.data.unsaved_object @@ -82,11 +84,11 @@ def test_title_minlength(self): def test_title_maxlength(self): expected_maximum_length = 255 - maximum_title = 'x' * expected_maximum_length + maximum_title = "x" * expected_maximum_length grant_obj = self.data.unsaved_object - grant_obj.title = maximum_title + 'x' + grant_obj.title = maximum_title + "x" with self.assertRaises(ValidationError): grant_obj.clean_fields() @@ -99,7 +101,7 @@ def test_title_maxlength(self): def test_grant_number_minlength(self): expected_minimum_length = 3 - minimum_grant_number = '1' * expected_minimum_length + minimum_grant_number = "1" * expected_minimum_length grant_obj = self.data.unsaved_object @@ -116,11 +118,11 @@ def test_grant_number_minlength(self): def test_grant_number_maxlength(self): expected_maximum_length = 255 - maximum_grant_number = '1' * expected_maximum_length + maximum_grant_number = "1" * expected_maximum_length grant_obj = self.data.unsaved_object - grant_obj.grant_number = maximum_grant_number + '1' + grant_obj.grant_number = maximum_grant_number + "1" with self.assertRaises(ValidationError): grant_obj.clean_fields() @@ -133,11 +135,11 @@ def test_grant_number_maxlength(self): def test_grant_pi_maxlength(self): expected_maximum_length = 255 - maximum_grant_pi_full_name = 'x' * expected_maximum_length + maximum_grant_pi_full_name = "x" * expected_maximum_length grant_obj = self.data.unsaved_object - grant_obj.grant_pi_full_name = maximum_grant_pi_full_name + 'x' + grant_obj.grant_pi_full_name = maximum_grant_pi_full_name + "x" with self.assertRaises(ValidationError): grant_obj.clean_fields() @@ -152,21 +154,21 @@ def test_grant_pi_optional(self): self.assertEqual(0, len(Grant.objects.all())) grant_obj = self.data.unsaved_object - grant_obj.grant_pi_full_name = '' + grant_obj.grant_pi_full_name = "" grant_obj.save() self.assertEqual(1, len(Grant.objects.all())) retrieved_obj = Grant.objects.get(pk=grant_obj.pk) - self.assertEqual('', retrieved_obj.grant_pi_full_name) + self.assertEqual("", retrieved_obj.grant_pi_full_name) def test_other_funding_agency_maxlength(self): expected_maximum_length = 255 - maximum_other_funding_agency = 'x' * expected_maximum_length + maximum_other_funding_agency = "x" * expected_maximum_length grant_obj = self.data.unsaved_object - grant_obj.other_funding_agency = maximum_other_funding_agency + 'x' + grant_obj.other_funding_agency = maximum_other_funding_agency + "x" with self.assertRaises(ValidationError): grant_obj.clean_fields() @@ -181,21 +183,21 @@ def test_other_funding_agency_optional(self): self.assertEqual(0, len(Grant.objects.all())) grant_obj = self.data.unsaved_object - grant_obj.other_funding_agency = '' + grant_obj.other_funding_agency = "" grant_obj.save() self.assertEqual(1, len(Grant.objects.all())) retrieved_obj = Grant.objects.get(pk=grant_obj.pk) - self.assertEqual('', retrieved_obj.other_funding_agency) + self.assertEqual("", retrieved_obj.other_funding_agency) def test_other_award_number_maxlength(self): expected_maximum_length = 255 - maxiumum_other_award_number = '1' * expected_maximum_length + maxiumum_other_award_number = "1" * expected_maximum_length grant_obj = self.data.unsaved_object - grant_obj.other_award_number = maxiumum_other_award_number + '1' + grant_obj.other_award_number = maxiumum_other_award_number + "1" with self.assertRaises(ValidationError): grant_obj.clean_fields() @@ -210,42 +212,43 @@ def test_other_award_number_optional(self): self.assertEqual(0, len(Grant.objects.all())) grant_obj = self.data.unsaved_object - grant_obj.other_award_number = '' + grant_obj.other_award_number = "" grant_obj.save() self.assertEqual(1, len(Grant.objects.all())) retrieved_obj = Grant.objects.get(pk=grant_obj.pk) - self.assertEqual('', retrieved_obj.other_award_number) + self.assertEqual("", retrieved_obj.other_award_number) def test_percent_credit_maxvalue(self): - expected_maximum_value = 100 + exceeds_maximum_value = "101" + normal_value = "100" grant_obj = self.data.unsaved_object - grant_obj.percent_credit = expected_maximum_value + 1 + grant_obj.percent_credit = exceeds_maximum_value with self.assertRaises(ValidationError): grant_obj.clean_fields() - grant_obj.percent_credit = expected_maximum_value + grant_obj.percent_credit = normal_value grant_obj.clean_fields() grant_obj.save() retrieved_obj = Grant.objects.get(pk=grant_obj.pk) - self.assertEqual(expected_maximum_value, retrieved_obj.percent_credit) + self.assertEqual(normal_value, retrieved_obj.percent_credit) def test_project_foreignkey_on_delete(self): - grant_obj = self.data.unsaved_object - grant_obj.save() + grant_obj = self.data.unsaved_object + grant_obj.save() - self.assertEqual(1, len(Grant.objects.all())) + self.assertEqual(1, len(Grant.objects.all())) - grant_obj.project.delete() + grant_obj.project.delete() - # expecting CASCADE - with self.assertRaises(Grant.DoesNotExist): - Grant.objects.get(pk=grant_obj.pk) - self.assertEqual(0, len(Grant.objects.all())) + # expecting CASCADE + with self.assertRaises(Grant.DoesNotExist): + Grant.objects.get(pk=grant_obj.pk) + self.assertEqual(0, len(Grant.objects.all())) def test_funding_agency_foreignkey_on_delete(self): grant_obj = self.data.unsaved_object @@ -272,4 +275,3 @@ def test_status_foreignkey_on_delete(self): with self.assertRaises(Grant.DoesNotExist): Grant.objects.get(pk=grant_obj.pk) self.assertEqual(0, len(Grant.objects.all())) - diff --git a/coldfront/core/grant/urls.py b/coldfront/core/grant/urls.py index 3a083ae5ef..1ac53e6d7e 100644 --- a/coldfront/core/grant/urls.py +++ b/coldfront/core/grant/urls.py @@ -1,11 +1,20 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.grant.views as grant_views urlpatterns = [ - path('project//create', grant_views.GrantCreateView.as_view(), name='grant-create'), - path('/update/', grant_views.GrantUpdateView.as_view(), name='grant-update'), - path('project//delete-grants/', grant_views.GrantDeleteGrantsView.as_view(), name='grant-delete-grants'), - path('grant-report/', grant_views.GrantReportView.as_view(), name='grant-report'), - path('grant-download/', grant_views.GrantDownloadView.as_view(), name='grant-download'), + path("project//create", grant_views.GrantCreateView.as_view(), name="grant-create"), + path("/update/", grant_views.GrantUpdateView.as_view(), name="grant-update"), + path( + "project//delete-grants/", + grant_views.GrantDeleteGrantsView.as_view(), + name="grant-delete-grants", + ), + path("grant-report/", grant_views.GrantReportView.as_view(), name="grant-report"), + path("grant-download/", grant_views.GrantDownloadView.as_view(), name="grant-download"), + path("data/summary/", grant_views.GrantSummaryDataView.as_view(), name="grant-summary-data"), ] diff --git a/coldfront/core/grant/views.py b/coldfront/core/grant/views.py index 72ac345eec..8bdad6174e 100644 --- a/coldfront/core/grant/views.py +++ b/coldfront/core/grant/views.py @@ -1,255 +1,272 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import csv from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.humanize.templatetags.humanize import intcomma +from django.db.models import Count, FloatField, Sum +from django.db.models.functions import Cast from django.forms import formset_factory -from django.http import HttpResponseRedirect, StreamingHttpResponse +from django.http import HttpResponseRedirect, JsonResponse, StreamingHttpResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.views import View -from django.views.generic import DetailView, FormView, ListView, TemplateView -from django.views.generic.edit import CreateView, UpdateView +from django.views.generic import FormView, ListView, TemplateView +from django.views.generic.edit import UpdateView -from coldfront.core.utils.common import Echo from coldfront.core.grant.forms import GrantDeleteForm, GrantDownloadForm, GrantForm -from coldfront.core.grant.models import (Grant, GrantFundingAgency, - GrantStatusChoice) +from coldfront.core.grant.models import Grant from coldfront.core.project.models import Project +from coldfront.core.utils.common import Echo class GrantCreateView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = GrantForm - template_name = 'grant/grant_create.html' + template_name = "grant/grant_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error(self.request, 'You do not have permission to add a new grant to this project.') + messages.error(self.request, "You do not have permission to add a new grant to this project.") def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error(request, 'You cannot add grants to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add grants to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def form_valid(self, form): form_data = form.cleaned_data - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) - grant_obj = Grant.objects.create( + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + Grant.objects.create( project=project_obj, - title=form_data.get('title'), - grant_number=form_data.get('grant_number'), - role=form_data.get('role'), - grant_pi_full_name=form_data.get('grant_pi_full_name'), - funding_agency=form_data.get('funding_agency'), - other_funding_agency=form_data.get('other_funding_agency'), - other_award_number=form_data.get('other_award_number'), - grant_start=form_data.get('grant_start'), - grant_end=form_data.get('grant_end'), - percent_credit=form_data.get('percent_credit'), - direct_funding=form_data.get('direct_funding'), - total_amount_awarded=form_data.get('total_amount_awarded'), - status=form_data.get('status'), + title=form_data.get("title"), + grant_number=form_data.get("grant_number"), + role=form_data.get("role"), + grant_pi_full_name=form_data.get("grant_pi_full_name"), + funding_agency=form_data.get("funding_agency"), + other_funding_agency=form_data.get("other_funding_agency"), + other_award_number=form_data.get("other_award_number"), + grant_start=form_data.get("grant_start"), + grant_end=form_data.get("grant_end"), + percent_credit=form_data.get("percent_credit"), + direct_funding=form_data.get("direct_funding"), + total_amount_awarded=form_data.get("total_amount_awarded"), + status=form_data.get("status"), ) return super().form_valid(form) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['project'] = Project.objects.get(pk=self.kwargs.get('project_pk')) + context["project"] = Project.objects.get(pk=self.kwargs.get("project_pk")) return context def get_success_url(self): - messages.success(self.request, 'Added a grant.') - return reverse('project-detail', kwargs={'pk': self.kwargs.get('project_pk')}) + messages.success(self.request, "Added a grant.") + return reverse("project-detail", kwargs={"pk": self.kwargs.get("project_pk")}) class GrantUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - grant_obj = get_object_or_404(Grant, pk=self.kwargs.get('pk')) + grant_obj = get_object_or_404(Grant, pk=self.kwargs.get("pk")) if grant_obj.project.pi == self.request.user: return True - if grant_obj.project.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if grant_obj.project.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error(self.request, 'You do not have permission to update grant from this project.') + messages.error(self.request, "You do not have permission to update grant from this project.") model = Grant - template_name_suffix = '_update_form' - fields = ['title', 'grant_number', 'role', 'grant_pi_full_name', 'funding_agency', 'other_funding_agency', - 'other_award_number', 'grant_start', 'grant_end', 'percent_credit', 'direct_funding', 'total_amount_awarded', 'status', ] + template_name_suffix = "_update_form" + fields = [ + "title", + "grant_number", + "role", + "grant_pi_full_name", + "funding_agency", + "other_funding_agency", + "other_award_number", + "grant_start", + "grant_end", + "percent_credit", + "direct_funding", + "total_amount_awarded", + "status", + ] def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.project.id}) + return reverse("project-detail", kwargs={"pk": self.object.project.id}) class GrantDeleteGrantsView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'grant/grant_delete_grants.html' + template_name = "grant/grant_delete_grants.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error(self.request, 'You do not have permission to delete grants from this project.') + messages.error(self.request, "You do not have permission to delete grants from this project.") def get_grants_to_delete(self, project_obj): - grants_to_delete = [ - - {'title': grant.title, - 'grant_number': grant.grant_number, - 'grant_end': grant.grant_end} - + {"title": grant.title, "grant_number": grant.grant_number, "grant_end": grant.grant_end} for grant in project_obj.grant_set.all() ] return grants_to_delete def get(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) grants_to_delete = self.get_grants_to_delete(project_obj) context = {} if grants_to_delete: formset = formset_factory(GrantDeleteForm, max_num=len(grants_to_delete)) - formset = formset(initial=grants_to_delete, prefix='grantform') - context['formset'] = formset + formset = formset(initial=grants_to_delete, prefix="grantform") + context["formset"] = formset - context['project'] = project_obj + context["project"] = project_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) grants_to_delete = self.get_grants_to_delete(project_obj) - context = {} formset = formset_factory(GrantDeleteForm, max_num=len(grants_to_delete)) - formset = formset(request.POST, initial=grants_to_delete, prefix='grantform') + formset = formset(request.POST, initial=grants_to_delete, prefix="grantform") grants_deleted_count = 0 if formset.is_valid(): for form in formset: grant_form_data = form.cleaned_data - if grant_form_data['selected']: - + if grant_form_data["selected"]: grant_obj = Grant.objects.get( project=project_obj, - title=grant_form_data.get('title'), - grant_number=grant_form_data.get('grant_number') + title=grant_form_data.get("title"), + grant_number=grant_form_data.get("grant_number"), ) grant_obj.delete() grants_deleted_count += 1 - messages.success(request, 'Deleted {} grants from project.'.format(grants_deleted_count)) + messages.success(request, "Deleted {} grants from project.".format(grants_deleted_count)) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.project.id}) + return reverse("project-detail", kwargs={"pk": self.object.project.id}) class GrantReportView(LoginRequiredMixin, UserPassesTestMixin, ListView): - template_name = 'grant/grant_report_list.html' + template_name = "grant/grant_report_list.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('grant.can_view_all_grants'): + if self.request.user.has_perm("grant.can_view_all_grants"): return True - messages.error(self.request, 'You do not have permission to view all grants.') - + messages.error(self.request, "You do not have permission to view all grants.") def get_grants(self): - grants = Grant.objects.prefetch_related( - 'project', 'project__pi').all().order_by('-total_amount_awarded') - grants= [ - - {'pk': grant.pk, - 'title': grant.title, - 'project_pk': grant.project.pk, - 'pi_first_name': grant.project.pi.first_name, - 'pi_last_name':grant.project.pi.last_name, - 'role': grant.role, - 'grant_pi': grant.grant_pi, - 'total_amount_awarded': grant.total_amount_awarded, - 'funding_agency': grant.funding_agency, - 'grant_number': grant.grant_number, - 'grant_start': grant.grant_start, - 'grant_end': grant.grant_end, - 'percent_credit': grant.percent_credit, - 'direct_funding': grant.direct_funding, + grants = Grant.objects.select_related("project", "project__pi").all().order_by("-total_amount_awarded") + grants = [ + { + "pk": grant.pk, + "title": grant.title, + "project_pk": grant.project.pk, + "pi_first_name": grant.project.pi.first_name, + "pi_last_name": grant.project.pi.last_name, + "role": grant.role, + "grant_pi": grant.grant_pi, + "total_amount_awarded": grant.total_amount_awarded, + "funding_agency": grant.funding_agency, + "grant_number": grant.grant_number, + "grant_start": grant.grant_start, + "grant_end": grant.grant_end, + "percent_credit": grant.percent_credit, + "direct_funding": grant.direct_funding, } for grant in grants ] return grants - def get(self, request, *args, **kwargs): context = {} grants = self.get_grants() if grants: formset = formset_factory(GrantDownloadForm, max_num=len(grants)) - formset = formset(initial=grants, prefix='grantdownloadform') - context['formset'] = formset + formset = formset(initial=grants, prefix="grantdownloadform") + context["formset"] = formset return render(request, self.template_name, context) - def post(self, request, *args, **kwargs): grants = self.get_grants() formset = formset_factory(GrantDownloadForm, max_num=len(grants)) - formset = formset(request.POST, initial=grants, prefix='grantdownloadform') + formset = formset(request.POST, initial=grants, prefix="grantdownloadform") header = [ - 'Grant Title', - 'Project PI', - 'Faculty Role', - 'Grant PI', - 'Total Amount Awarded', - 'Funding Agency', - 'Grant Number', - 'Start Date', - 'End Date', - 'Percent Credit', - 'Direct Funding', + "Grant Title", + "Project PI", + "Faculty Role", + "Grant PI", + "Total Amount Awarded", + "Funding Agency", + "Grant Number", + "Start Date", + "End Date", + "Percent Credit", + "Direct Funding", ] rows = [] grants_selected_count = 0 @@ -257,12 +274,14 @@ def post(self, request, *args, **kwargs): if formset.is_valid(): for form in formset: form_data = form.cleaned_data - if form_data['selected']: - grant = get_object_or_404(Grant, pk=form_data['pk']) + if form_data["selected"]: + grant = get_object_or_404( + Grant.objects.select_related("project", "project__pi"), pk=form_data["pk"] + ) row = [ grant.title, - ' '.join((grant.project.pi.first_name, grant.project.pi.last_name)), + " ".join((grant.project.pi.first_name, grant.project.pi.last_name)), grant.role, grant.grant_pi_full_name, grant.total_amount_awarded, @@ -278,11 +297,11 @@ def post(self, request, *args, **kwargs): grants_selected_count += 1 if grants_selected_count == 0: - grants = Grant.objects.prefetch_related('project', 'project__pi').all().order_by('-total_amount_awarded') + grants = Grant.objects.select_related("project", "project__pi").all().order_by("-total_amount_awarded") for grant in grants: row = [ grant.title, - ' '.join((grant.project.pi.first_name, grant.project.pi.last_name)), + " ".join((grant.project.pi.first_name, grant.project.pi.last_name)), grant.role, grant.grant_pi_full_name, grant.total_amount_awarded, @@ -298,51 +317,49 @@ def post(self, request, *args, **kwargs): rows.insert(0, header) pseudo_buffer = Echo() writer = csv.writer(pseudo_buffer) - response = StreamingHttpResponse((writer.writerow(row) for row in rows), - content_type="text/csv") - response['Content-Disposition'] = 'attachment; filename="grants.csv"' + response = StreamingHttpResponse((writer.writerow(row) for row in rows), content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="grants.csv"' return response else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('grant-report')) + return HttpResponseRedirect(reverse("grant-report")) class GrantDownloadView(LoginRequiredMixin, UserPassesTestMixin, View): login_url = "/" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('grant.can_view_all_grants'): + if self.request.user.has_perm("grant.can_view_all_grants"): return True - messages.error(self.request, 'You do not have permission to download all grants.') + messages.error(self.request, "You do not have permission to download all grants.") def get(self, request): - header = [ - 'Grant Title', - 'Project PI', - 'Faculty Role', - 'Grant PI', - 'Total Amount Awarded', - 'Funding Agency', - 'Grant Number', - 'Start Date', - 'End Date', - 'Percent Credit', - 'Direct Funding', + "Grant Title", + "Project PI", + "Faculty Role", + "Grant PI", + "Total Amount Awarded", + "Funding Agency", + "Grant Number", + "Start Date", + "End Date", + "Percent Credit", + "Direct Funding", ] rows = [] - grants = Grant.objects.prefetch_related('project', 'project__pi').all().order_by('-total_amount_awarded') + grants = Grant.objects.prefetch_related("project", "project__pi").all().order_by("-total_amount_awarded") for grant in grants: row = [ grant.title, - ' '.join((grant.project.pi.first_name, grant.project.pi.last_name)), + " ".join((grant.project.pi.first_name, grant.project.pi.last_name)), grant.role, grant.grant_pi_full_name, grant.total_amount_awarded, @@ -358,7 +375,30 @@ def get(self, request): rows.insert(0, header) pseudo_buffer = Echo() writer = csv.writer(pseudo_buffer) - response = StreamingHttpResponse((writer.writerow(row) for row in rows), - content_type="text/csv") - response['Content-Disposition'] = 'attachment; filename="grants.csv"' + response = StreamingHttpResponse((writer.writerow(row) for row in rows), content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="grants.csv"' return response + + +class GrantSummaryDataView(View): + def get(self, request, *args, **kwargs): + data = {"data": []} + grants = {} + for row in Grant.objects.values("funding_agency__name").annotate( + total_amount=Sum(Cast("total_amount_awarded", FloatField())) + ): + grants[row["funding_agency__name"]] = {"total": row["total_amount"]} + + for row in Grant.objects.values("funding_agency__name").annotate(count=Count("total_amount_awarded")): + grants[row["funding_agency__name"]]["count"] = row["count"] + + for name, rec in grants.items(): + data["data"].append( + { + "total": rec["total"], + "name": f"{name}: ${intcomma(int(rec['total']))} ({rec['count']})", + } + ) + + data["data"] = sorted(data["data"], key=lambda d: d["total"], reverse=True) + return JsonResponse(data) diff --git a/coldfront/core/portal/__init__.py b/coldfront/core/portal/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/portal/__init__.py +++ b/coldfront/core/portal/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/portal/admin.py b/coldfront/core/portal/admin.py index 2b05ba5487..a49c53902b 100644 --- a/coldfront/core/portal/admin.py +++ b/coldfront/core/portal/admin.py @@ -1,15 +1,21 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from django.contrib.admin.models import LogEntry @admin.register(LogEntry) class LogEntryAdmin(admin.ModelAdmin): - list_display = ('content_type', - 'user', - 'action_time', - 'object_id', - 'object_repr', - 'action_flag', - 'change_message',) - - search_fields = ['user__username', 'user__first_name', 'user__last_name'] + list_display = ( + "content_type", + "user", + "action_time", + "object_id", + "object_repr", + "action_flag", + "change_message", + ) + + search_fields = ["user__username", "user__first_name", "user__last_name"] diff --git a/coldfront/core/portal/apps.py b/coldfront/core/portal/apps.py index 988452e2af..e1c682b52f 100644 --- a/coldfront/core/portal/apps.py +++ b/coldfront/core/portal/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class PortalConfig(AppConfig): - name = 'coldfront.core.portal' + name = "coldfront.core.portal" diff --git a/coldfront/core/portal/migrations/__init__.py b/coldfront/core/portal/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/portal/migrations/__init__.py +++ b/coldfront/core/portal/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/portal/models.py b/coldfront/core/portal/models.py index 71a8362390..73294d7dba 100644 --- a/coldfront/core/portal/models.py +++ b/coldfront/core/portal/models.py @@ -1,3 +1,5 @@ -from django.db import models +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your models here. diff --git a/coldfront/core/portal/templates/portal/allocation_by_fos.html b/coldfront/core/portal/templates/portal/allocation_by_fos.html index 1f8a8bf187..850149f374 100644 --- a/coldfront/core/portal/templates/portal/allocation_by_fos.html +++ b/coldfront/core/portal/templates/portal/allocation_by_fos.html @@ -1,6 +1,6 @@ {% load common_tags %}
-
Grant Title Project PI Faculty Role
+
diff --git a/coldfront/core/portal/templates/portal/allocation_summary.html b/coldfront/core/portal/templates/portal/allocation_summary.html index 5a4592287f..269f25b3bb 100644 --- a/coldfront/core/portal/templates/portal/allocation_summary.html +++ b/coldfront/core/portal/templates/portal/allocation_summary.html @@ -1,75 +1,19 @@ {% load static %} -
-
-
-
-
-
-
-
Field of Science
- - - - - - - - {% for resource, resource_allocation_count in allocations_count_by_resource.items %} - - - - - {% endfor %} - -
Resource Name (Type)Active Allocation Count
{{resource.name}} ({{resource.resource_type.name}}){{resource_allocation_count}}
-
-
+
+ + + + + + + + + {% for resource, resource_allocation_count in allocations_count_by_resource.items %} + + + + + {% endfor %} + +
Resource Name (Type)Active Allocation Count
{{resource.name}} ({{resource.resource_type.name}}){{resource_allocation_count}}
- - - diff --git a/coldfront/core/portal/templates/portal/authorized_home.html b/coldfront/core/portal/templates/portal/authorized_home.html index 5fef17e0e1..122c9e1495 100644 --- a/coldfront/core/portal/templates/portal/authorized_home.html +++ b/coldfront/core/portal/templates/portal/authorized_home.html @@ -1,5 +1,6 @@ {% extends "common/base.html" %} {% load common_tags %} +{% load static %} {% block content %} @@ -13,9 +14,9 @@

Projects »

-
+
{% include "portal/extra_app_templates.html" %}
{% endblock %} - - -{% block javascript %} -{{ block.super }} - -{% endblock %} diff --git a/coldfront/core/portal/templates/portal/carousel.html b/coldfront/core/portal/templates/portal/carousel.html deleted file mode 100644 index 8d35ea87bc..0000000000 --- a/coldfront/core/portal/templates/portal/carousel.html +++ /dev/null @@ -1,37 +0,0 @@ -{% load static %} - \ No newline at end of file diff --git a/coldfront/core/portal/templates/portal/center_summary.html b/coldfront/core/portal/templates/portal/center_summary.html index 2de21d120c..627387a3b9 100644 --- a/coldfront/core/portal/templates/portal/center_summary.html +++ b/coldfront/core/portal/templates/portal/center_summary.html @@ -4,7 +4,6 @@ {% load common_tags %} {% load humanize %} - {% block title %} Center Summary {% endblock %} @@ -16,13 +15,15 @@

{% settings_value 'CENTER_NAME' %} Scientific Impact

{% if settings.PUBLICATION_ENABLE %} -
-
+
+
User Entered Publications
-
- Total Publications: {{total_publications_count}} +
+ +
+ Total Publications: 0
@@ -30,12 +31,12 @@

{% settings_value 'CENTER_NAME' %} Scientific Impact

{% if settings.RESEARCH_OUTPUT_ENABLE %} -
-
+
+
User Entered Research Outputs
- Total Publications: {{total_research_outputs_count}} + Total: {{research_outputs_count}}
@@ -43,159 +44,62 @@

{% settings_value 'CENTER_NAME' %} Scientific Impact

{% if settings.GRANT_ENABLE %} -
-
+
+
User Grants Summary
-
+
+ +

- Grants Total: ${{grants_total}}
- Grants Total PI Only: ${{grants_total_pi_only}}
- Grants Total CoPI Only: ${{grants_total_copi_only}}
- Grants Total Senior Personnel Only: ${{grants_total_sp_only}} + Grants Total: ${{grant_total}}
+ Grants Total PI Only: ${{grant_total_pi_only}}
+ Grants Total CoPI Only: ${{grant_total_copi_only}}
+ Grants Total Senior Personnel Only: ${{grant_total_sp_only}}
{% endif %} -
-
+
+
Active Allocations and Users by Field of Science
-
+
+ class="visually-hidden">...
-
-
+
+
Resources and Allocations Summary
-
-
- -
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
- {% endblock %} diff --git a/coldfront/core/portal/templates/portal/extra_app_templates.html b/coldfront/core/portal/templates/portal/extra_app_templates.html index 8a46581eab..9d6d88a9db 100644 --- a/coldfront/core/portal/templates/portal/extra_app_templates.html +++ b/coldfront/core/portal/templates/portal/extra_app_templates.html @@ -5,3 +5,7 @@ {% if 'coldfront.plugins.system_monitor' in EXTRA_APPS %} {% include "system_monitor/system_monitor_div.html" %} {% endif %} + +{% if 'coldfront.plugins.slurm' in EXTRA_APPS and user.is_authenticated %} + {% include "slurm/full_slurm_help_div.html" %} +{% endif %} diff --git a/coldfront/core/portal/templates/portal/nonauthorized_home.html b/coldfront/core/portal/templates/portal/nonauthorized_home.html index 10d9a96a7c..35a46b848c 100644 --- a/coldfront/core/portal/templates/portal/nonauthorized_home.html +++ b/coldfront/core/portal/templates/portal/nonauthorized_home.html @@ -8,7 +8,7 @@

Log In to ColdFront


-

Log In

+

@@ -20,12 +20,3 @@

Do not have an Account?

{% include "portal/extra_app_templates.html" %}
{% endblock %} - - -{% block javascript %} - {{ block.super }} - -{% endblock %} diff --git a/coldfront/core/portal/templatetags/__init__.py b/coldfront/core/portal/templatetags/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/portal/templatetags/__init__.py +++ b/coldfront/core/portal/templatetags/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/portal/templatetags/portal_tags.py b/coldfront/core/portal/templatetags/portal_tags.py index 692cf9a2e0..cc430f1a26 100644 --- a/coldfront/core/portal/templatetags/portal_tags.py +++ b/coldfront/core/portal/templatetags/portal_tags.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import template from django.conf import settings diff --git a/coldfront/core/portal/tests.py b/coldfront/core/portal/tests.py deleted file mode 100644 index 7ce503c2dd..0000000000 --- a/coldfront/core/portal/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/coldfront/core/portal/tests/__init__.py b/coldfront/core/portal/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/portal/tests/test_views.py b/coldfront/core/portal/tests/test_views.py new file mode 100644 index 0000000000..7e37f80f5e --- /dev/null +++ b/coldfront/core/portal/tests/test_views.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging + +from django.test import TestCase + +from coldfront.core.test_helpers import utils + +logging.disable(logging.CRITICAL) + + +class PortalViewBaseTest(TestCase): + """Base class for portal view tests.""" + + @classmethod + def setUpTestData(cls): + """Test Data setup for all portal view tests.""" + pass + + +class CenterSummaryViewTest(PortalViewBaseTest): + """Tests for center summary view""" + + @classmethod + def setUpTestData(cls): + """Set up users and project for testing""" + cls.url = "/center-summary" + super(PortalViewBaseTest, cls).setUpTestData() + + def test_centersummary_renders(self): + response = self.client.get(self.url) + utils.assert_response_success(self, response) + self.assertContains(response, "Active Allocations and Users") + self.assertContains(response, "Resources and Allocations Summary") + self.assertNotContains(response, "We're having a bit of system trouble at the moment. Please check back soon!") diff --git a/coldfront/core/portal/tests/tests.py b/coldfront/core/portal/tests/tests.py new file mode 100644 index 0000000000..576ead011d --- /dev/null +++ b/coldfront/core/portal/tests/tests.py @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Create your tests here. diff --git a/coldfront/core/portal/urls.py b/coldfront/core/portal/urls.py new file mode 100644 index 0000000000..4fe17c2cdf --- /dev/null +++ b/coldfront/core/portal/urls.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.urls import path + +import coldfront.core.portal.views as portal_views + +urlpatterns = [ + path( + "data/allocation-by-status/", + portal_views.allocation_by_status, + name="portal-allocation-status", + ), + path( + "data/resource-by-type/", + portal_views.resource_by_type, + name="portal-resource-type", + ), +] diff --git a/coldfront/core/portal/utils.py b/coldfront/core/portal/utils.py deleted file mode 100644 index fa5ae8dd11..0000000000 --- a/coldfront/core/portal/utils.py +++ /dev/null @@ -1,112 +0,0 @@ -import datetime - -from coldfront.core.allocation.models import Allocation - - -def generate_publication_by_year_chart_data(publications_by_year): - - if publications_by_year: - years, publications = zip(*publications_by_year) - years = list(years) - publications = list(publications) - years.insert(0, "Year") - publications.insert(0, "Publications") - - data = { - "x": "Year", - "columns": [ - years, - publications - ], - "type": "bar", - "colors": { - "Publications": '#17a2b8' - } - } - else: - data = { - "columns": [], - "type": 'bar' - } - - return data - - -def generate_total_grants_by_agency_chart_data(total_grants_by_agency): - - grants_agency_chart_data = { - "columns": total_grants_by_agency, - "type": 'donut' - } - - return grants_agency_chart_data - - -def generate_resources_chart_data(allocations_count_by_resource_type): - - - if allocations_count_by_resource_type: - cluster_label = "Cluster: %d" % (allocations_count_by_resource_type.get('Cluster', 0)) - cloud_label = "Cloud: %d" % (allocations_count_by_resource_type.get('Cloud', 0)) - server_label = "Server: %d" % (allocations_count_by_resource_type.get('Server', 0)) - storage_label = "Storage: %d" % (allocations_count_by_resource_type.get('Storage', 0)) - - resource_plot_data = { - "columns": [ - [cluster_label, allocations_count_by_resource_type.get('Cluster', 0)], - [storage_label, allocations_count_by_resource_type.get('Storage', 0)], - [cloud_label, allocations_count_by_resource_type.get('Cloud', 0)], - [server_label, allocations_count_by_resource_type.get('Server', 0)] - - ], - "type": 'donut', - "colors": { - cluster_label: '#6da04b', - storage_label: '#ffc72c', - cloud_label: '#2f9fd0', - server_label: '#e56a54', - - } - } - else: - resource_plot_data = { - "type": 'donut', - "columns": [] - } - - return resource_plot_data - - -def generate_allocations_chart_data(): - - active_count = Allocation.objects.filter(status__name='Active').count() - new_count = Allocation.objects.filter(status__name='New').count() - renewal_requested_count = Allocation.objects.filter(status__name='Renewal Requested').count() - - now = datetime.datetime.now() - start_time = datetime.date(now.year - 1, 1, 1) - expired_count = Allocation.objects.filter( - status__name='Expired', end_date__gte=start_time).count() - - active_label = "Active: %d" % (active_count) - new_label = "New: %d" % (new_count) - renewal_requested_label = "Renewal Requested: %d" % (renewal_requested_count) - expired_label = "Expired: %d" % (expired_count) - - allocation_chart_data = { - "columns": [ - [active_label, active_count], - [new_label, new_count], - [renewal_requested_label, renewal_requested_count], - [expired_label, expired_count], - ], - "type": 'donut', - "colors": { - active_label: '#6da04b', - new_label: '#2f9fd0', - renewal_requested_label: '#ffc72c', - expired_label: '#e56a54', - } - } - - return allocation_chart_data diff --git a/coldfront/core/portal/views.py b/coldfront/core/portal/views.py index 6c861e0b07..0a1c2f814c 100644 --- a/coldfront/core/portal/views.py +++ b/coldfront/core/portal/views.py @@ -1,56 +1,108 @@ -import operator +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import datetime from collections import Counter from django.conf import settings from django.contrib.humanize.templatetags.humanize import intcomma -from django.db.models import Count, Q, Sum +from django.db.models import FloatField, Q, Sum +from django.db.models.functions import Cast +from django.http import JsonResponse from django.shortcuts import render from django.views.decorators.cache import cache_page from coldfront.core.allocation.models import Allocation, AllocationUser from coldfront.core.grant.models import Grant -from coldfront.core.portal.utils import (generate_allocations_chart_data, - generate_publication_by_year_chart_data, - generate_resources_chart_data, - generate_total_grants_by_agency_chart_data) from coldfront.core.project.models import Project -from coldfront.core.publication.models import Publication from coldfront.core.research_output.models import ResearchOutput +from coldfront.core.utils.common import import_from_settings +ALLOCATION_EULA_ENABLE = import_from_settings("ALLOCATION_EULA_ENABLE", False) -def home(request): +def home(request): context = {} if request.user.is_authenticated: - template_name = 'portal/authorized_home.html' - project_list = Project.objects.filter( - (Q(pi=request.user) & Q(status__name__in=['New', 'Active', ])) | - (Q(status__name__in=['New', 'Active', ]) & - Q(projectuser__user=request.user) & - Q(projectuser__status__name__in=['Active', ])) - ).distinct().order_by('-created')[:5] - - allocation_list = Allocation.objects.filter( - Q(status__name__in=['Active', 'New', 'Renewal Requested', ]) & - Q(project__status__name__in=['Active', 'New']) & - Q(project__projectuser__user=request.user) & - Q(project__projectuser__status__name__in=['Active', ]) & - Q(allocationuser__user=request.user) & - Q(allocationuser__status__name__in=['Active', ]) - ).distinct().order_by('-created')[:5] - context['project_list'] = project_list - context['allocation_list'] = allocation_list + template_name = "portal/authorized_home.html" + project_list = ( + Project.objects.select_related("status") + .filter( + ( + Q(pi=request.user) + & Q( + status__name__in=[ + "New", + "Active", + ] + ) + ) + | ( + Q( + status__name__in=[ + "New", + "Active", + ] + ) + & Q(projectuser__user=request.user) + & Q( + projectuser__status__name__in=[ + "Active", + ] + ) + ) + ) + .distinct() + .order_by("-created")[:5] + ) + + allocation_list = ( + Allocation.objects.select_related("status", "project") + .filter( + Q( + status__name__in=[ + "Active", + "New", + "Renewal Requested", + ] + ) + & Q(project__status__name__in=["Active", "New"]) + & Q(project__projectuser__user=request.user) + & Q( + project__projectuser__status__name__in=[ + "Active", + ] + ) + & Q(allocationuser__user=request.user) + & Q(allocationuser__status__name__in=["Active", "PendingEULA"]) + ) + .distinct() + .order_by("-created")[:5] + ) + + if ALLOCATION_EULA_ENABLE: + user_status = [] + for allocation in allocation_list: + if allocation.allocationuser_set.filter(user=request.user).exists(): + user_status.append(allocation.allocationuser_set.get(user=request.user).status.name) + context["user_status"] = user_status + + context["project_list"] = project_list + context["allocation_list"] = allocation_list + try: - context['ondemand_url'] = settings.ONDEMAND_URL + context["ondemand_url"] = settings.ONDEMAND_URL except AttributeError: pass else: - template_name = 'portal/nonauthorized_home.html' + template_name = "portal/nonauthorized_home.html" - context['EXTRA_APPS'] = settings.INSTALLED_APPS + context["EXTRA_APPS"] = settings.INSTALLED_APPS - if 'coldfront.plugins.system_monitor' in settings.INSTALLED_APPS: + if "coldfront.plugins.system_monitor" in settings.INSTALLED_APPS: from coldfront.plugins.system_monitor.utils import get_system_monitor_context + context.update(get_system_monitor_context()) return render(request, template_name, context) @@ -58,98 +110,103 @@ def home(request): def center_summary(request): context = {} - - # Publications Card - publications_by_year = list(Publication.objects.filter(year__gte=1999).values( - 'unique_id', 'year').distinct().values('year').annotate(num_pub=Count('year')).order_by('-year')) - - publications_by_year = [(ele['year'], ele['num_pub']) - for ele in publications_by_year] - - publication_by_year_bar_chart_data = generate_publication_by_year_chart_data( - publications_by_year) - context['publication_by_year_bar_chart_data'] = publication_by_year_bar_chart_data - context['total_publications_count'] = Publication.objects.filter( - year__gte=1999).values('unique_id', 'year').distinct().count() - - # Research Outputs card - context['total_research_outputs_count'] = ResearchOutput.objects.all().distinct().count() - - # Grants Card - total_grants_by_agency_sum = list(Grant.objects.values( - 'funding_agency__name').annotate(total_amount=Sum('total_amount_awarded'))) - - total_grants_by_agency_count = list(Grant.objects.values( - 'funding_agency__name').annotate(count=Count('total_amount_awarded'))) - - total_grants_by_agency_count = { - ele['funding_agency__name']: ele['count'] for ele in total_grants_by_agency_count} - - total_grants_by_agency = [['{}: ${} ({})'.format( - ele['funding_agency__name'], - intcomma(int(ele['total_amount'])), - total_grants_by_agency_count[ele['funding_agency__name']] - ), ele['total_amount']] for ele in total_grants_by_agency_sum] - - total_grants_by_agency = sorted( - total_grants_by_agency, key=operator.itemgetter(1), reverse=True) - grants_agency_chart_data = generate_total_grants_by_agency_chart_data( - total_grants_by_agency) - context['grants_agency_chart_data'] = grants_agency_chart_data - context['grants_total'] = intcomma( - int(sum(list(Grant.objects.values_list('total_amount_awarded', flat=True))))) - context['grants_total_pi_only'] = intcomma( - int(sum(list(Grant.objects.filter(role='PI').values_list('total_amount_awarded', flat=True))))) - context['grants_total_copi_only'] = intcomma( - int(sum(list(Grant.objects.filter(role='CoPI').values_list('total_amount_awarded', flat=True))))) - context['grants_total_sp_only'] = intcomma( - int(sum(list(Grant.objects.filter(role='SP').values_list('total_amount_awarded', flat=True))))) - - return render(request, 'portal/center_summary.html', context) + context["research_outputs_count"] = ResearchOutput.objects.all().distinct().count() + + sum_agg = Sum(Cast("total_amount_awarded", FloatField()), default=0) + context["grant_total"] = intcomma( + int(Grant.objects.aggregate(total_amount_awarded__sum=sum_agg)["total_amount_awarded__sum"]) + ) + context["grant_total_pi_only"] = intcomma( + int(Grant.objects.filter(role="PI").aggregate(total_amount_awarded__sum=sum_agg)["total_amount_awarded__sum"]) + ) + context["grant_total_copi_only"] = intcomma( + int(Grant.objects.filter(role="CoPI").aggregate(total_amount_awarded__sum=sum_agg)["total_amount_awarded__sum"]) + ) + context["grant_total_sp_only"] = intcomma( + int(Grant.objects.filter(role="SP").aggregate(total_amount_awarded__sum=sum_agg)["total_amount_awarded__sum"]) + ) + return render(request, "portal/center_summary.html", context) @cache_page(60 * 15) def allocation_by_fos(request): + allocations_by_fos = Counter( + list( + Allocation.objects.filter(status__name="Active").values_list( + "project__field_of_science__description", flat=True + ) + ) + ) + + user_allocations = AllocationUser.objects.filter(status__name="Active", allocation__status__name="Active") + + active_users_by_fos = Counter( + list(user_allocations.values_list("allocation__project__field_of_science__description", flat=True)) + ) + total_allocations_users = user_allocations.values("user").distinct().count() + + active_pi_count = ( + Project.objects.filter(status__name__in=["Active", "New"]) + .values_list("pi__username", flat=True) + .distinct() + .count() + ) + context = {} + context["allocations_by_fos"] = dict(allocations_by_fos) + context["active_users_by_fos"] = dict(active_users_by_fos) + context["total_allocations_users"] = total_allocations_users + context["active_pi_count"] = active_pi_count + return render(request, "portal/allocation_by_fos.html", context) - allocations_by_fos = Counter(list(Allocation.objects.filter( - status__name='Active').values_list('project__field_of_science__description', flat=True))) - user_allocations = AllocationUser.objects.filter( - status__name='Active', allocation__status__name='Active') +@cache_page(60 * 15) +def allocation_summary(request): + allocation_resources = [] + for allocation in Allocation.objects.filter(status__name="Active"): + parent_resource = allocation.get_parent_resource + allocation_resources.append( + parent_resource.parent_resource if parent_resource.parent_resource else parent_resource + ) - active_users_by_fos = Counter(list(user_allocations.values_list( - 'allocation__project__field_of_science__description', flat=True))) - total_allocations_users = user_allocations.values( - 'user').distinct().count() + allocations_count_by_resource = dict(Counter(allocation_resources)) - active_pi_count = Project.objects.filter(status__name__in=['Active', 'New']).values_list( - 'pi__username', flat=True).distinct().count() context = {} - context['allocations_by_fos'] = dict(allocations_by_fos) - context['active_users_by_fos'] = dict(active_users_by_fos) - context['total_allocations_users'] = total_allocations_users - context['active_pi_count'] = active_pi_count - return render(request, 'portal/allocation_by_fos.html', context) + context["allocations_count_by_resource"] = allocations_count_by_resource + return render(request, "portal/allocation_summary.html", context) -@cache_page(60 * 15) -def allocation_summary(request): - allocation_resources = [ - allocation.get_parent_resource.parent_resource if allocation.get_parent_resource.parent_resource else allocation.get_parent_resource for allocation in Allocation.objects.filter(status__name='Active')] +def allocation_by_status(request): + data = [ + {"name": "Active", "total": Allocation.objects.filter(status__name="Active").count()}, + {"name": "New", "total": Allocation.objects.filter(status__name="New").count()}, + {"name": "Renewal Requested", "total": Allocation.objects.filter(status__name="Renewal Requested").count()}, + ] - allocations_count_by_resource = dict(Counter(allocation_resources)) + now = datetime.datetime.now() + start_time = datetime.date(now.year - 1, 1, 1) + data.append( + { + "name": "Expired", + "total": Allocation.objects.filter(status__name="Expired", end_date__gte=start_time).count(), + } + ) - allocation_count_by_resource_type = dict( - Counter([ele.resource_type.name for ele in allocation_resources])) + return JsonResponse({"data": data}) - allocations_chart_data = generate_allocations_chart_data() - resources_chart_data = generate_resources_chart_data( - allocation_count_by_resource_type) - context = {} - context['allocations_chart_data'] = allocations_chart_data - context['allocations_count_by_resource'] = allocations_count_by_resource - context['resources_chart_data'] = resources_chart_data +def resource_by_type(request): + allocation_resources = [] + for allocation in Allocation.objects.filter(status__name="Active"): + parent_resource = allocation.get_parent_resource + allocation_resources.append( + parent_resource.parent_resource if parent_resource.parent_resource else parent_resource + ) + + allocation_count_by_resource_type = dict(Counter([ele.resource_type.name for ele in allocation_resources])) + + data = [] + for rtype in ["Cluster", "Cloud", "Server", "Storage"]: + data.append({"name": rtype, "total": allocation_count_by_resource_type.get(rtype, 0)}) - return render(request, 'portal/allocation_summary.html', context) + return JsonResponse({"data": data}) diff --git a/coldfront/core/project/__init__.py b/coldfront/core/project/__init__.py index 8e75e3212b..6d24412f63 100644 --- a/coldfront/core/project/__init__.py +++ b/coldfront/core/project/__init__.py @@ -1 +1,4 @@ -default_app_config = 'coldfront.core.project.apps.ProjectConfig' +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + diff --git a/coldfront/core/project/admin.py b/coldfront/core/project/admin.py index 61dd884222..400df4824c 100644 --- a/coldfront/core/project/admin.py +++ b/coldfront/core/project/admin.py @@ -1,53 +1,86 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import textwrap from django.contrib import admin -from simple_history.admin import SimpleHistoryAdmin from django.utils.translation import gettext_lazy as _ +from simple_history.admin import SimpleHistoryAdmin -from coldfront.core.project.models import (Project, ProjectAdminComment, - ProjectReview, ProjectStatusChoice, - ProjectUser, ProjectUserMessage, - ProjectUserRoleChoice, - ProjectUserStatusChoice, - ProjectAttribute, - ProjectAttributeType, - AttributeType, - ProjectAttributeUsage) +from coldfront.core.project.models import ( + AttributeType, + Project, + ProjectAdminComment, + ProjectAttribute, + ProjectAttributeType, + ProjectAttributeUsage, + ProjectReview, + ProjectStatusChoice, + ProjectUser, + ProjectUserMessage, + ProjectUserRoleChoice, + ProjectUserStatusChoice, +) +from coldfront.core.utils.common import import_from_settings + +PROJECT_CODE = import_from_settings("PROJECT_CODE", False) +PROJECT_INSTITUTION_EMAIL_MAP = import_from_settings("PROJECT_INSTITUTION_EMAIL_MAP", False) @admin.register(ProjectStatusChoice) class ProjectStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('name',) + list_display = ("name",) @admin.register(ProjectUserRoleChoice) class ProjectUserRoleChoiceAdmin(admin.ModelAdmin): - list_display = ('name',) + list_display = ("name",) @admin.register(ProjectUserStatusChoice) class ProjectUserStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('name',) + list_display = ("name",) @admin.register(ProjectUser) class ProjectUserAdmin(SimpleHistoryAdmin): - fields_change = ('user', 'project', 'role', 'status', 'created', 'modified', ) - readonly_fields_change = ('user', 'project', 'created', 'modified', ) - list_display = ('pk', 'project_title', 'PI', 'User', 'role', 'status', - 'created', 'modified',) - list_filter = ('role', 'status') - search_fields = ['user__username', 'user__first_name', 'user__last_name'] - raw_id_fields = ('user', 'project') + fields_change = ( + "user", + "project", + "role", + "status", + "created", + "modified", + ) + readonly_fields_change = ( + "user", + "project", + "created", + "modified", + ) + list_display = ( + "pk", + "project_title", + "PI", + "User", + "role", + "status", + "created", + "modified", + ) + list_filter = ("role", "status") + search_fields = ["user__username", "user__first_name", "user__last_name"] + raw_id_fields = ("user", "project") def project_title(self, obj): return textwrap.shorten(obj.project.title, width=50) def PI(self, obj): - return '{} {} ({})'.format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) + return "{} {} ({})".format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) def User(self, obj): - return '{} {} ({})'.format(obj.user.first_name, obj.user.last_name, obj.user.username) + return "{} {} ({})".format(obj.user.first_name, obj.user.last_name, obj.user.username) def get_fields(self, request, obj): if obj is None: @@ -72,39 +105,51 @@ def get_inline_instances(self, request, obj=None): class ProjectUserInline(admin.TabularInline): model = ProjectUser - fields = ['user', 'project', 'role', 'status', 'enable_notifications', ] - readonly_fields = ['user', 'project', ] + fields = [ + "user", + "project", + "role", + "status", + "enable_notifications", + ] + readonly_fields = [ + "user", + "project", + ] extra = 0 class ProjectAdminCommentInline(admin.TabularInline): model = ProjectAdminComment extra = 0 - fields = ('comment', 'author', 'created'), - readonly_fields = ('author', 'created') + fields = (("comment", "author", "created"),) + readonly_fields = ("author", "created") class ProjectUserMessageInline(admin.TabularInline): model = ProjectUserMessage extra = 0 - fields = ('message', 'author', 'created'), - readonly_fields = ('author', 'created') + fields = (("message", "author", "created"),) + readonly_fields = ("author", "created") class ProjectAttributeInLine(admin.TabularInline): model = ProjectAttribute extra = 0 - fields = ('proj_attr_type', 'value',) + fields = ( + "proj_attr_type", + "value", + ) @admin.register(AttributeType) class AttributeTypeAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ("name",) @admin.register(ProjectAttributeType) class ProjectAttributeTypeAdmin(admin.ModelAdmin): - list_display = ('pk', 'name', 'attribute_type', 'has_usage', 'is_private') + list_display = ("pk", "name", "attribute_type", "has_usage", "is_private") class ProjectAttributeUsageInline(admin.TabularInline): @@ -113,64 +158,78 @@ class ProjectAttributeUsageInline(admin.TabularInline): class UsageValueFilter(admin.SimpleListFilter): - title = _('value') + title = _("value") - parameter_name = 'value' + parameter_name = "value" def lookups(self, request, model_admin): return ( - ('>=0', _('Greater than or equal to 0')), - ('>10', _('Greater than 10')), - ('>100', _('Greater than 100')), - ('>1000', _('Greater than 1000')), - ('>10000', _('Greater than 10000')), + (">=0", _("Greater than or equal to 0")), + (">10", _("Greater than 10")), + (">100", _("Greater than 100")), + (">1000", _("Greater than 1000")), + (">10000", _("Greater than 10000")), ) def queryset(self, request, queryset): - - if self.value() == '>=0': + if self.value() == ">=0": return queryset.filter(allocationattributeusage__value__gte=0) - if self.value() == '>10': + if self.value() == ">10": return queryset.filter(allocationattributeusage__value__gte=10) - if self.value() == '>100': + if self.value() == ">100": return queryset.filter(allocationattributeusage__value__gte=100) - if self.value() == '>1000': + if self.value() == ">1000": return queryset.filter(allocationattributeusage__value__gte=1000) + @admin.register(ProjectAttribute) class ProjectAttributeAdmin(SimpleHistoryAdmin): - readonly_fields_change = ( - 'proj_attr_type', 'created', 'modified', 'project_title') - fields_change = ('project_title', - 'proj_attr_type', 'value', 'created', 'modified',) - list_display = ('pk', 'project', 'pi', 'project_status', - 'proj_attr_type', 'value', 'usage', 'created', 'modified',) - inlines = [ProjectAttributeUsageInline, ] - list_filter = (UsageValueFilter, 'proj_attr_type', - 'project__status') + readonly_fields_change = ("proj_attr_type", "created", "modified", "project_title") + fields_change = ( + "project_title", + "proj_attr_type", + "value", + "created", + "modified", + ) + list_display = ( + "pk", + "project", + "pi", + "project_status", + "proj_attr_type", + "value", + "usage", + "created", + "modified", + ) + inlines = [ + ProjectAttributeUsageInline, + ] + list_filter = (UsageValueFilter, "proj_attr_type", "project__status") search_fields = ( - 'project__pi__first_name', - 'project__pi__last_name', - 'project__pi__username', - 'project__projectuser__user__first_name', - 'project__projectuser__user__last_name', - 'project__projectuser__user__username', + "project__pi__first_name", + "project__pi__last_name", + "project__pi__username", + "project__projectuser__user__first_name", + "project__projectuser__user__last_name", + "project__projectuser__user__username", ) def usage(self, obj): - if hasattr(obj, 'projectattributeusage'): + if hasattr(obj, "projectattributeusage"): return obj.projectattributeusage.value else: - return 'N/A' + return "N/A" def project_status(self, obj): return obj.project.status def pi(self, obj): - return '{} {} ({})'.format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) + return "{} {} ({})".format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) def project(self, obj): return textwrap.shorten(obj.project.title, width=50) @@ -200,40 +259,49 @@ def get_inline_instances(self, request, obj=None): class ValueFilter(admin.SimpleListFilter): - title = _('value') + title = _("value") - parameter_name = 'value' + parameter_name = "value" def lookups(self, request, model_admin): return ( - ('>0', _('Greater than > 0')), - ('>10', _('Greater than > 10')), - ('>100', _('Greater than > 100')), - ('>1000', _('Greater than > 1000')), + (">0", _("Greater than > 0")), + (">10", _("Greater than > 10")), + (">100", _("Greater than > 100")), + (">1000", _("Greater than > 1000")), ) def queryset(self, request, queryset): - - if self.value() == '>0': + if self.value() == ">0": return queryset.filter(value__gt=0) - if self.value() == '>10': + if self.value() == ">10": return queryset.filter(value__gt=10) - if self.value() == '>100': + if self.value() == ">100": return queryset.filter(value__gt=100) - if self.value() == '>1000': + if self.value() == ">1000": return queryset.filter(value__gt=1000) + @admin.register(ProjectAttributeUsage) class ProjectAttributeUsageAdmin(SimpleHistoryAdmin): - list_display = ('project_attribute', 'project', - 'project_pi', 'value',) - readonly_fields = ('project_attribute',) - fields = ('project_attribute', 'value',) - list_filter = ('project_attribute__proj_attr_type', - ValueFilter, ) + list_display = ( + "project_attribute", + "project", + "project_pi", + "value", + ) + readonly_fields = ("project_attribute",) + fields = ( + "project_attribute", + "value", + ) + list_filter = ( + "project_attribute__proj_attr_type", + ValueFilter, + ) def project(self, obj): return obj.project_attribute.project.title @@ -241,19 +309,39 @@ def project(self, obj): def project_pi(self, obj): return obj.project_attribute.project.pi.username + @admin.register(Project) class ProjectAdmin(SimpleHistoryAdmin): - fields_change = ('title', 'pi', 'description', 'status', 'requires_review', 'force_review', 'created', 'modified', ) - readonly_fields_change = ('created', 'modified', ) - list_display = ('pk', 'title', 'PI', 'created', 'modified', 'status') - search_fields = ['pi__username', 'projectuser__user__username', - 'projectuser__user__last_name', 'projectuser__user__last_name', 'title'] - list_filter = ('status', 'force_review') + fields_change = ( + "title", + "pi", + "description", + "status", + "requires_review", + "force_review", + "created", + "modified", + ) + readonly_fields_change = ( + "created", + "modified", + ) + list_display = ("pk", "title", "PI", "created", "modified", "status") + search_fields = [ + "pi__username", + "projectuser__user__username", + "projectuser__user__last_name", + "projectuser__user__last_name", + "title", + ] + list_filter = ("status", "force_review") inlines = [ProjectUserInline, ProjectAdminCommentInline, ProjectUserMessageInline, ProjectAttributeInLine] - raw_id_fields = ['pi', ] + raw_id_fields = [ + "pi", + ] def PI(self, obj): - return '{} {} ({})'.format(obj.pi.first_name, obj.pi.last_name, obj.pi.username) + return "{} {} ({})".format(obj.pi.first_name, obj.pi.last_name, obj.pi.username) def get_fields(self, request, obj): if obj is None: @@ -275,6 +363,20 @@ def get_inline_instances(self, request, obj=None): else: return super().get_inline_instances(request) + def get_list_display(self, request): + if not (PROJECT_CODE or PROJECT_INSTITUTION_EMAIL_MAP): + return self.list_display + + list_display = list(self.list_display) + + if PROJECT_CODE: + list_display.insert(1, "project_code") + + if PROJECT_INSTITUTION_EMAIL_MAP: + list_display.insert(2, "institution") + + return tuple(list_display) + def save_formset(self, request, form, formset, change): if formset.model in [ProjectAdminComment, ProjectUserMessage]: instances = formset.save(commit=False) @@ -287,10 +389,13 @@ def save_formset(self, request, form, formset, change): @admin.register(ProjectReview) class ProjectReviewAdmin(SimpleHistoryAdmin): - list_display = ('pk', 'project', 'PI', 'reason_for_not_updating_project', 'created', 'status') - search_fields = ['project__pi__username', 'project__pi__first_name', 'project__pi__last_name',] - list_filter = ('status', ) + list_display = ("pk", "project", "PI", "reason_for_not_updating_project", "created", "status") + search_fields = [ + "project__pi__username", + "project__pi__first_name", + "project__pi__last_name", + ] + list_filter = ("status",) def PI(self, obj): - return '{} {} ({})'.format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) - + return "{} {} ({})".format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) diff --git a/coldfront/core/project/apps.py b/coldfront/core/project/apps.py index 84bbd81163..17a291a8ad 100644 --- a/coldfront/core/project/apps.py +++ b/coldfront/core/project/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class ProjectConfig(AppConfig): - name = 'coldfront.core.project' + name = "coldfront.core.project" diff --git a/coldfront/core/project/forms.py b/coldfront/core/project/forms.py index beeea24e9c..51852f31fc 100644 --- a/coldfront/core/project/forms.py +++ b/coldfront/core/project/forms.py @@ -1,35 +1,33 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime from django import forms from django.db.models.functions import Lower from django.shortcuts import get_object_or_404 -from ast import Constant -from django.db.models.functions import Lower -from cProfile import label -from coldfront.core.project.models import (Project, ProjectAttribute, ProjectAttributeType, ProjectReview, - ProjectUserRoleChoice) +from coldfront.core.project.models import Project, ProjectAttribute, ProjectReview, ProjectUserRoleChoice from coldfront.core.utils.common import import_from_settings -EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL = import_from_settings( - 'EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL') -EMAIL_ADMIN_LIST = import_from_settings('EMAIL_ADMIN_LIST', []) -EMAIL_DIRECTOR_EMAIL_ADDRESS = import_from_settings( - 'EMAIL_DIRECTOR_EMAIL_ADDRESS', '') +EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL = import_from_settings("EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL") +EMAIL_ADMIN_LIST = import_from_settings("EMAIL_ADMIN_LIST", []) +EMAIL_DIRECTOR_EMAIL_ADDRESS = import_from_settings("EMAIL_DIRECTOR_EMAIL_ADDRESS", "") class ProjectSearchForm(forms.Form): - """ Search form for the Project list page. - """ - LAST_NAME = 'Last Name' - USERNAME = 'Username' - FIELD_OF_SCIENCE = 'Field of Science' - - last_name = forms.CharField( - label=LAST_NAME, max_length=100, required=False) + """Search form for the Project list page.""" + + TITLE = "Title" + LAST_NAME = "Last Name" + USERNAME = "Username" + FIELD_OF_SCIENCE = "Field of Science" + + title = forms.CharField(label=TITLE, max_length=255, required=False) + last_name = forms.CharField(label=LAST_NAME, max_length=100, required=False) username = forms.CharField(label=USERNAME, max_length=100, required=False) - field_of_science = forms.CharField( - label=FIELD_OF_SCIENCE, max_length=100, required=False) + field_of_science = forms.CharField(label=FIELD_OF_SCIENCE, max_length=100, required=False) show_all_projects = forms.BooleanField(initial=False, required=False) @@ -39,31 +37,17 @@ class ProjectAddUserForm(forms.Form): last_name = forms.CharField(max_length=150, required=False, disabled=True) email = forms.EmailField(max_length=100, required=False, disabled=True) source = forms.CharField(max_length=16, disabled=True) - role = forms.ModelChoiceField( - queryset=ProjectUserRoleChoice.objects.all(), empty_label=None) + role = forms.ModelChoiceField(queryset=ProjectUserRoleChoice.objects.all(), empty_label=None) selected = forms.BooleanField(initial=False, required=False) class ProjectAddUsersToAllocationForm(forms.Form): - allocation = forms.MultipleChoiceField( - widget=forms.CheckboxSelectMultiple(attrs={'checked': 'checked'}), required=False) - - def __init__(self, request_user, project_pk, *args, **kwargs): - super().__init__(*args, **kwargs) - project_obj = get_object_or_404(Project, pk=project_pk) - - allocation_query_set = project_obj.allocation_set.filter( - resources__is_allocatable=True, is_locked=False, status__name__in=['Active', 'New', 'Renewal Requested', 'Payment Pending', 'Payment Requested', 'Paid']) - allocation_choices = [(allocation.id, "%s (%s) %s" % (allocation.get_parent_resource.name, allocation.get_parent_resource.resource_type.name, - allocation.description if allocation.description else '')) for allocation in allocation_query_set] - allocation_choices_sorted = [] - allocation_choices_sorted = sorted(allocation_choices, key=lambda x: x[1][0].lower()) - allocation_choices.insert(0, ('__select_all__', 'Select All')) - if allocation_query_set: - self.fields['allocation'].choices = allocation_choices_sorted - self.fields['allocation'].help_text = '
Select allocations to add selected users to.' - else: - self.fields['allocation'].widget = forms.HiddenInput() + pk = forms.IntegerField(disabled=True) + selected = forms.BooleanField(initial=False, required=False) + resource = forms.CharField(max_length=50, disabled=True) + details = forms.CharField(max_length=300, disabled=True, required=False) + resource_type = forms.CharField(max_length=50, disabled=True) + status = forms.CharField(max_length=50, disabled=True) class ProjectRemoveUserForm(forms.Form): @@ -76,16 +60,25 @@ class ProjectRemoveUserForm(forms.Form): class ProjectUserUpdateForm(forms.Form): - role = forms.ModelChoiceField( - queryset=ProjectUserRoleChoice.objects.all(), empty_label=None) + role = forms.ModelChoiceField(queryset=ProjectUserRoleChoice.objects.all(), empty_label=None) enable_notifications = forms.BooleanField(initial=False, required=False) class ProjectReviewForm(forms.Form): - reason = forms.CharField(label='Reason for not updating project information', widget=forms.Textarea(attrs={ - 'placeholder': 'If you have no new information to provide, you are required to provide a statement explaining this in this box. Thank you!'}), required=False) + reason = forms.CharField( + label="Reason for not updating project information", + widget=forms.Textarea( + attrs={ + "placeholder": "If you have no new information to provide, you are required to provide a statement explaining this in this box. Thank you!" + } + ), + required=False, + ) acknowledgement = forms.BooleanField( - label='By checking this box I acknowledge that I have updated my project to the best of my knowledge', initial=False, required=True) + label="By checking this box I acknowledge that I have updated my project to the best of my knowledge", + initial=False, + required=True, + ) def __init__(self, project_pk, *args, **kwargs): super().__init__(*args, **kwargs) @@ -93,57 +86,52 @@ def __init__(self, project_pk, *args, **kwargs): now = datetime.datetime.now(datetime.timezone.utc) if project_obj.grant_set.exists(): - latest_grant = project_obj.grant_set.order_by('-modified')[0] - grant_updated_in_last_year = ( - now - latest_grant.created).days < 365 + latest_grant = project_obj.grant_set.order_by("-modified")[0] + grant_updated_in_last_year = (now - latest_grant.created).days < 365 else: grant_updated_in_last_year = None if project_obj.publication_set.exists(): - latest_publication = project_obj.publication_set.order_by( - '-created')[0] - publication_updated_in_last_year = ( - now - latest_publication.created).days < 365 + latest_publication = project_obj.publication_set.order_by("-created")[0] + publication_updated_in_last_year = (now - latest_publication.created).days < 365 else: publication_updated_in_last_year = None if grant_updated_in_last_year or publication_updated_in_last_year: - self.fields['reason'].widget = forms.HiddenInput() + self.fields["reason"].widget = forms.HiddenInput() else: - self.fields['reason'].required = True + self.fields["reason"].required = True class ProjectReviewEmailForm(forms.Form): - cc = forms.CharField( - required=False - ) - email_body = forms.CharField( - required=True, - widget=forms.Textarea - ) + cc = forms.CharField(required=False) + email_body = forms.CharField(required=True, widget=forms.Textarea) def __init__(self, pk, *args, **kwargs): super().__init__(*args, **kwargs) project_review_obj = get_object_or_404(ProjectReview, pk=int(pk)) - self.fields['email_body'].initial = 'Dear {} {} \n{}'.format( - project_review_obj.project.pi.first_name, project_review_obj.project.pi.last_name, EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL) - self.fields['cc'].initial = ', '.join( - [EMAIL_DIRECTOR_EMAIL_ADDRESS] + EMAIL_ADMIN_LIST) + self.fields["email_body"].initial = "Dear {} {} \n{}".format( + project_review_obj.project.pi.first_name, + project_review_obj.project.pi.last_name, + EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL, + ) + self.fields["cc"].initial = ", ".join([EMAIL_DIRECTOR_EMAIL_ADDRESS] + EMAIL_ADMIN_LIST) + -class ProjectAttributeAddForm(forms.ModelForm): +class ProjectAttributeAddForm(forms.ModelForm): class Meta: - fields = '__all__' + fields = "__all__" model = ProjectAttribute labels = { - 'proj_attr_type' : "Project Attribute Type", + "proj_attr_type": "Project Attribute Type", } def __init__(self, *args, **kwargs): - super(ProjectAttributeAddForm, self).__init__(*args, **kwargs) - user =(kwargs.get('initial')).get('user') - self.fields['proj_attr_type'].queryset = self.fields['proj_attr_type'].queryset.order_by(Lower('name')) - if not user.is_superuser: - self.fields['proj_attr_type'].queryset = self.fields['proj_attr_type'].queryset.filter(is_private=False) + super(ProjectAttributeAddForm, self).__init__(*args, **kwargs) + user = kwargs.get("initial").get("user") + queryset = self.fields["proj_attr_type"].queryset.select_related("attribute_type").order_by(Lower("name")) + self.fields["proj_attr_type"].queryset = queryset if user.is_superuser else queryset.filter(is_private=False) + class ProjectAttributeDeleteForm(forms.Form): pk = forms.IntegerField(required=False, disabled=True) @@ -154,7 +142,8 @@ class ProjectAttributeDeleteForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() + # class ProjectAttributeChangeForm(forms.Form): # pk = forms.IntegerField(required=False, disabled=True) @@ -181,12 +170,22 @@ class ProjectAttributeUpdateForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() def clean(self): cleaned_data = super().clean() - if cleaned_data.get('new_value') != "": - proj_attr = ProjectAttribute.objects.get(pk=cleaned_data.get('pk')) - proj_attr.value = cleaned_data.get('new_value') + if cleaned_data.get("new_value") != "": + proj_attr = ProjectAttribute.objects.get(pk=cleaned_data.get("pk")) + proj_attr.value = cleaned_data.get("new_value") proj_attr.clean() + + +class ProjectCreationForm(forms.ModelForm): + class Meta: + model = Project + fields = ["title", "description", "field_of_science"] + + def __init__(self, *args, **kwargs): + super(ProjectCreationForm, self).__init__(*args, **kwargs) + self.fields["field_of_science"].widget.attrs["class"] = "fos-select2" diff --git a/coldfront/core/project/management/__init__.py b/coldfront/core/project/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/project/management/__init__.py +++ b/coldfront/core/project/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/project/management/commands/__init__.py b/coldfront/core/project/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/project/management/commands/__init__.py +++ b/coldfront/core/project/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/project/management/commands/add_default_project_choices.py b/coldfront/core/project/management/commands/add_default_project_choices.py index 5b3ccb2c82..8b06af65d8 100644 --- a/coldfront/core/project/management/commands/add_default_project_choices.py +++ b/coldfront/core/project/management/commands/add_default_project_choices.py @@ -1,37 +1,61 @@ -import os -from inspect import Attribute +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.management.base import BaseCommand -from coldfront.core.project.models import (ProjectAttributeType, - ProjectReviewStatusChoice, - ProjectStatusChoice, - ProjectUserRoleChoice, - ProjectUserStatusChoice, - AttributeType) +from coldfront.core.project.models import ( + AttributeType, + ProjectAttributeType, + ProjectReviewStatusChoice, + ProjectStatusChoice, + ProjectUserRoleChoice, + ProjectUserStatusChoice, +) class Command(BaseCommand): - help = 'Add default project related choices' + help = "Add default project related choices" def handle(self, *args, **options): - for choice in ['New', 'Active', 'Archived', ]: + for choice in [ + "New", + "Active", + "Archived", + ]: ProjectStatusChoice.objects.get_or_create(name=choice) - for choice in ['Completed', 'Pending', ]: + for choice in [ + "Completed", + "Pending", + ]: ProjectReviewStatusChoice.objects.get_or_create(name=choice) - for choice in ['User', 'Manager', ]: + for choice in [ + "User", + "Manager", + ]: ProjectUserRoleChoice.objects.get_or_create(name=choice) - for choice in ['Active', 'Pending - Add', 'Pending - Remove', 'Denied', 'Removed', ]: + for choice in [ + "Active", + "Pending - Add", + "Pending - Remove", + "Denied", + "Removed", + ]: ProjectUserStatusChoice.objects.get_or_create(name=choice) - for attribute_type in ('Date', 'Float', 'Int', 'Text', 'Yes/No'): + for attribute_type in ("Date", "Float", "Int", "Text", "Yes/No"): AttributeType.objects.get_or_create(name=attribute_type) for name, attribute_type, has_usage, is_private in ( - ('Project ID', 'Text', False, False), - ('Account Number', 'Int', False, True), + ("Project ID", "Text", False, False), + ("Account Number", "Int", False, True), ): - ProjectAttributeType.objects.get_or_create(name=name, attribute_type=AttributeType.objects.get( - name=attribute_type), has_usage=has_usage, is_private=is_private) + ProjectAttributeType.objects.get_or_create( + name=name, + attribute_type=AttributeType.objects.get(name=attribute_type), + has_usage=has_usage, + is_private=is_private, + ) diff --git a/coldfront/core/project/management/commands/add_institutions.py b/coldfront/core/project/management/commands/add_institutions.py new file mode 100644 index 0000000000..8e2ab13f22 --- /dev/null +++ b/coldfront/core/project/management/commands/add_institutions.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.core.management.base import BaseCommand + +from coldfront.core.project.models import Project +from coldfront.core.project.utils import determine_automated_institution_choice +from coldfront.core.utils.common import import_from_settings + +PROJECT_INSTITUTION_EMAIL_MAP = import_from_settings("PROJECT_INSTITUTION_EMAIL_MAP", False) + + +class Command(BaseCommand): + help = "Update existing projects with institutions based on PIs email address" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Outputs each project, followed by the assigned institution, without making changes.", + ) + + def update_project_institution(self, projects): + if not PROJECT_INSTITUTION_EMAIL_MAP: + self.stdout.write( + "Error, no changes made. Please set PROJECT_INSTITUTION_EMAIL_MAP as a dictionary value inside configuration file." + ) + return + + def _update_project_institution(self, projects): + user_input = input( + "Assign all existing projects with institutions? You can use the --dry-run flag to preview changes first. [y/N] " + ) + + try: + if user_input == "y" or user_input == "Y": + for project in projects: + project.institution = determine_automated_institution_choice(project, PROJECT_INSTITUTION_EMAIL_MAP) + project.save(update_fields=["institution"]) + self.stdout.write(f"Updated {projects.count()} projects with institutions.") + else: + self.stdout.write("No changes made") + except Exception as e: + self.stdout.write(f"Error: {e}") + + def _institution_dry_run(self, projects): + try: + for project in projects: + new_institution = determine_automated_institution_choice(project, PROJECT_INSTITUTION_EMAIL_MAP) + self.stdout.write( + f"Project {project.pk}, called {project.title}. Institution would be '{new_institution}'" + ) + except Exception as e: + self.stdout.write(f"Error: {e}") + + def handle(self, *args, **options): + dry_run = options["dry_run"] + + if not PROJECT_INSTITUTION_EMAIL_MAP: + self.stdout.write( + "Error, no changes made. Please set PROJECT_INSTITUTION_EMAIL_MAP as a dictionary value inside configuration file." + ) + return + + projects_without_institution = Project.objects.filter(institution="None") + + if dry_run: + self._institution_dry_run(projects_without_institution) + else: + self._update_project_institution(projects_without_institution) diff --git a/coldfront/core/project/management/commands/add_project_codes.py b/coldfront/core/project/management/commands/add_project_codes.py new file mode 100644 index 0000000000..7ae8617505 --- /dev/null +++ b/coldfront/core/project/management/commands/add_project_codes.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.core.management.base import BaseCommand + +from coldfront.core.project.models import Project +from coldfront.core.project.utils import generate_project_code +from coldfront.core.utils.common import import_from_settings + +PROJECT_CODE = import_from_settings("PROJECT_CODE", False) +PROJECT_CODE_PADDING = import_from_settings("PROJECT_CODE_PADDING", False) + + +class Command(BaseCommand): + help = "Update existing projects with project codes." + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Outputting project primary keys and titled, followed by their updated project code", + ) + + def update_project_code(self, projects): + user_input = input( + "Assign all existing projects with project codes? You can use the --dry-run flag to preview changes first. [y/N] " + ) + + try: + if user_input == "y" or user_input == "Y": + for project in projects: + project.project_code = generate_project_code(PROJECT_CODE, project.pk, PROJECT_CODE_PADDING) + project.save(update_fields=["project_code"]) + self.stdout.write(f"Updated {projects.count()} projects with project codes") + else: + self.stdout.write("No changes made") + except AttributeError: + self.stdout.write( + "Error, no changes made. Please set PROJECT_CODE as a string value inside configuration file." + ) + + def project_code_dry_run(self, projects): + try: + for project in projects: + new_code = generate_project_code(PROJECT_CODE, project.pk, PROJECT_CODE_PADDING) + self.stdout.write( + f"Project {project.pk}, called {project.title}: new project_code would be '{new_code}'" + ) + except AttributeError: + self.stdout.write( + "Error, no changes made. Please set PROJECT_CODE as a string value inside configuration file." + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + projects_without_codes = Project.objects.filter(project_code="") + + if dry_run: + self.project_code_dry_run(projects_without_codes) + else: + self.update_project_code(projects_without_codes) diff --git a/coldfront/core/project/migrations/0001_initial.py b/coldfront/core/project/migrations/0001_initial.py index 94792bacac..350ef7fd7a 100644 --- a/coldfront/core/project/migrations/0001_initial.py +++ b/coldfront/core/project/migrations/0001_initial.py @@ -1,224 +1,518 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('field_of_science', '0001_initial'), + ("field_of_science", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Project', + name="Project", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(default='\nWe do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!\n ', validators=[django.core.validators.MinLengthValidator(10, 'The project description must be > 10 characters.')])), - ('force_review', models.BooleanField(default=False)), - ('requires_review', models.BooleanField(default=True)), - ('field_of_science', models.ForeignKey(default=149, on_delete=django.db.models.deletion.CASCADE, to='field_of_science.FieldOfScience')), - ('pi', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(max_length=255)), + ( + "description", + models.TextField( + default="\nWe do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!\n ", + validators=[ + django.core.validators.MinLengthValidator( + 10, "The project description must be > 10 characters." + ) + ], + ), + ), + ("force_review", models.BooleanField(default=False)), + ("requires_review", models.BooleanField(default=True)), + ( + "field_of_science", + models.ForeignKey( + default=149, on_delete=django.db.models.deletion.CASCADE, to="field_of_science.FieldOfScience" + ), + ), + ("pi", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'ordering': ['title'], - 'permissions': (('can_view_all_projects', 'Can view all projects'), ('can_review_pending_project_reviews', 'Can review pending project reviews')), + "ordering": ["title"], + "permissions": ( + ("can_view_all_projects", "Can view all projects"), + ("can_review_pending_project_reviews", "Can review pending project reviews"), + ), }, ), migrations.CreateModel( - name='ProjectReviewStatusChoice', + name="ProjectReviewStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ProjectStatusChoice', + name="ProjectStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ('name',), + "ordering": ("name",), }, ), migrations.CreateModel( - name='ProjectUserRoleChoice', + name="ProjectUserRoleChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ProjectUserStatusChoice', + name="ProjectUserStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ProjectUserMessage', + name="ProjectUserMessage", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('message', models.TextField()), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("message", models.TextField()), + ("author", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='ProjectReview', + name="ProjectReview", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('reason_for_not_updating_project', models.TextField(blank=True, null=True)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), - ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.ProjectReviewStatusChoice', verbose_name='Status')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("reason_for_not_updating_project", models.TextField(blank=True, null=True)), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), + ( + "status", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="project.ProjectReviewStatusChoice", + verbose_name="Status", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='ProjectAdminComment', + name="ProjectAdminComment", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('comment', models.TextField()), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("comment", models.TextField()), + ("author", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.AddField( - model_name='project', - name='status', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.ProjectStatusChoice'), + model_name="project", + name="status", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.ProjectStatusChoice"), ), migrations.CreateModel( - name='HistoricalProjectUser', + name="HistoricalProjectUser", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('enable_notifications', models.BooleanField(default=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), - ('role', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.ProjectUserRoleChoice')), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.ProjectUserStatusChoice', verbose_name='Status')), - ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("enable_notifications", models.BooleanField(default=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), + ( + "role", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.ProjectUserRoleChoice", + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.ProjectUserStatusChoice", + verbose_name="Status", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical project user', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project user", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalProjectReview', + name="HistoricalProjectReview", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('reason_for_not_updating_project', models.TextField(blank=True, null=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.ProjectReviewStatusChoice', verbose_name='Status')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("reason_for_not_updating_project", models.TextField(blank=True, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.ProjectReviewStatusChoice", + verbose_name="Status", + ), + ), ], options={ - 'verbose_name': 'historical project review', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project review", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalProject', + name="HistoricalProject", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(default='\nWe do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!\n ', validators=[django.core.validators.MinLengthValidator(10, 'The project description must be > 10 characters.')])), - ('force_review', models.BooleanField(default=False)), - ('requires_review', models.BooleanField(default=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('field_of_science', models.ForeignKey(blank=True, db_constraint=False, default=149, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='field_of_science.FieldOfScience')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('pi', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.ProjectStatusChoice')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(max_length=255)), + ( + "description", + models.TextField( + default="\nWe do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!\n ", + validators=[ + django.core.validators.MinLengthValidator( + 10, "The project description must be > 10 characters." + ) + ], + ), + ), + ("force_review", models.BooleanField(default=False)), + ("requires_review", models.BooleanField(default=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "field_of_science", + models.ForeignKey( + blank=True, + db_constraint=False, + default=149, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="field_of_science.FieldOfScience", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "pi", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.ProjectStatusChoice", + ), + ), ], options={ - 'verbose_name': 'historical project', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='ProjectUser', + name="ProjectUser", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('enable_notifications', models.BooleanField(default=True)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), - ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.ProjectUserRoleChoice')), - ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.ProjectUserStatusChoice', verbose_name='Status')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("enable_notifications", models.BooleanField(default=True)), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), + ( + "role", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.ProjectUserRoleChoice"), + ), + ( + "status", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="project.ProjectUserStatusChoice", + verbose_name="Status", + ), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'verbose_name_plural': 'Project User Status', - 'unique_together': {('user', 'project')}, + "verbose_name_plural": "Project User Status", + "unique_together": {("user", "project")}, }, ), ] diff --git a/coldfront/core/project/migrations/0002_projectusermessage_is_private.py b/coldfront/core/project/migrations/0002_projectusermessage_is_private.py index 5c6833e40c..7a7214ce7d 100644 --- a/coldfront/core/project/migrations/0002_projectusermessage_is_private.py +++ b/coldfront/core/project/migrations/0002_projectusermessage_is_private.py @@ -1,18 +1,21 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 3.2.13 on 2022-06-06 15:35 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('project', '0001_initial'), + ("project", "0001_initial"), ] operations = [ migrations.AddField( - model_name='projectusermessage', - name='is_private', + model_name="projectusermessage", + name="is_private", field=models.BooleanField(default=True), ), ] diff --git a/coldfront/core/project/migrations/0003_auto_20221013_1215.py b/coldfront/core/project/migrations/0003_auto_20221013_1215.py index 2f9ad74e39..293b3fae8b 100644 --- a/coldfront/core/project/migrations/0003_auto_20221013_1215.py +++ b/coldfront/core/project/migrations/0003_auto_20221013_1215.py @@ -1,150 +1,307 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 3.2.15 on 2022-10-13 16:15 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('project', '0002_projectusermessage_is_private'), + ("project", "0002_projectusermessage_is_private"), ] operations = [ migrations.CreateModel( - name='AttributeType', + name="AttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ProjectAttribute', + name="ProjectAttribute", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=128)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=128)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='ProjectAttributeUsage', + name="ProjectAttributeUsage", fields=[ - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('project_attribute', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='project.projectattribute')), - ('value', models.FloatField(default=0)), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "project_attribute", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="project.projectattribute", + ), + ), + ("value", models.FloatField(default=0)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='ProjectAttributeType', + name="ProjectAttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=50)), - ('has_usage', models.BooleanField(default=False)), - ('is_required', models.BooleanField(default=False)), - ('is_unique', models.BooleanField(default=False)), - ('is_private', models.BooleanField(default=True)), - ('is_changeable', models.BooleanField(default=False)), - ('attribute_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.attributetype')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=50)), + ("has_usage", models.BooleanField(default=False)), + ("is_required", models.BooleanField(default=False)), + ("is_unique", models.BooleanField(default=False)), + ("is_private", models.BooleanField(default=True)), + ("is_changeable", models.BooleanField(default=False)), + ( + "attribute_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.attributetype"), + ), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.AddField( - model_name='projectattribute', - name='proj_attr_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.projectattributetype'), + model_name="projectattribute", + name="proj_attr_type", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.projectattributetype"), ), migrations.AddField( - model_name='projectattribute', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.project'), + model_name="projectattribute", + name="project", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.project"), ), migrations.CreateModel( - name='HistoricalProjectAttributeUsage', + name="HistoricalProjectAttributeUsage", fields=[ - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.FloatField(default=0)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project_attribute', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.projectattribute')), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.FloatField(default=0)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project_attribute", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.projectattribute", + ), + ), ], options={ - 'verbose_name': 'historical project attribute usage', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project attribute usage", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalProjectAttributeType', + name="HistoricalProjectAttributeType", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=50)), - ('has_usage', models.BooleanField(default=False)), - ('is_required', models.BooleanField(default=False)), - ('is_unique', models.BooleanField(default=False)), - ('is_private', models.BooleanField(default=True)), - ('is_changeable', models.BooleanField(default=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('attribute_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.attributetype')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=50)), + ("has_usage", models.BooleanField(default=False)), + ("is_required", models.BooleanField(default=False)), + ("is_unique", models.BooleanField(default=False)), + ("is_private", models.BooleanField(default=True)), + ("is_changeable", models.BooleanField(default=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "attribute_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.attributetype", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical project attribute type', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project attribute type", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalProjectAttribute', + name="HistoricalProjectAttribute", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=128)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('proj_attr_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.projectattributetype')), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.project')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=128)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "proj_attr_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.projectattributetype", + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.project", + ), + ), ], options={ - 'verbose_name': 'historical project attribute', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project attribute", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), diff --git a/coldfront/core/project/migrations/0004_auto_20230406_1133.py b/coldfront/core/project/migrations/0004_auto_20230406_1133.py index 07932a0db2..c9bedc5508 100644 --- a/coldfront/core/project/migrations/0004_auto_20230406_1133.py +++ b/coldfront/core/project/migrations/0004_auto_20230406_1133.py @@ -1,28 +1,31 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 3.2.17 on 2023-04-06 15:33 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('project', '0003_auto_20221013_1215'), + ("project", "0003_auto_20221013_1215"), ] operations = [ migrations.AlterField( - model_name='projectstatuschoice', - name='name', + model_name="projectstatuschoice", + name="name", field=models.CharField(max_length=64, unique=True), ), migrations.AlterField( - model_name='projectuserrolechoice', - name='name', + model_name="projectuserrolechoice", + name="name", field=models.CharField(max_length=64, unique=True), ), migrations.AlterField( - model_name='projectuserstatuschoice', - name='name', + model_name="projectuserstatuschoice", + name="name", field=models.CharField(max_length=64, unique=True), ), ] diff --git a/coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py b/coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py new file mode 100644 index 0000000000..e34e2cf50a --- /dev/null +++ b/coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Generated by Django 4.2.11 on 2025-03-12 13:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("project", "0004_auto_20230406_1133"), + ] + + operations = [ + migrations.AlterModelOptions( + name="historicalproject", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project", + "verbose_name_plural": "historical projects", + }, + ), + migrations.AlterModelOptions( + name="historicalprojectattribute", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project attribute", + "verbose_name_plural": "historical project attributes", + }, + ), + migrations.AlterModelOptions( + name="historicalprojectattributetype", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project attribute type", + "verbose_name_plural": "historical project attribute types", + }, + ), + migrations.AlterModelOptions( + name="historicalprojectattributeusage", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project attribute usage", + "verbose_name_plural": "historical project attribute usages", + }, + ), + migrations.AlterModelOptions( + name="historicalprojectreview", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project review", + "verbose_name_plural": "historical project reviews", + }, + ), + migrations.AlterModelOptions( + name="historicalprojectuser", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project user", + "verbose_name_plural": "historical Project User Status", + }, + ), + migrations.AddField( + model_name="historicalproject", + name="project_code", + field=models.CharField(blank=True, max_length=10), + ), + migrations.AddField( + model_name="project", + name="project_code", + field=models.CharField(blank=True, max_length=10), + ), + migrations.AlterField( + model_name="historicalproject", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalprojectattribute", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalprojectattributetype", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalprojectattributeusage", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalprojectreview", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalprojectuser", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterUniqueTogether( + name="project", + unique_together={("title", "pi")}, + ), + ] diff --git a/coldfront/core/project/migrations/0006_historicalproject_institution_project_institution.py b/coldfront/core/project/migrations/0006_historicalproject_institution_project_institution.py new file mode 100644 index 0000000000..7d362ead9f --- /dev/null +++ b/coldfront/core/project/migrations/0006_historicalproject_institution_project_institution.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Generated by Django 4.2.11 on 2025-04-27 19:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("project", "0005_alter_historicalproject_options_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="historicalproject", + name="institution", + field=models.CharField(blank=True, default="None", max_length=80), + ), + migrations.AddField( + model_name="project", + name="institution", + field=models.CharField(blank=True, default="None", max_length=80), + ), + ] diff --git a/coldfront/core/project/migrations/__init__.py b/coldfront/core/project/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/project/migrations/__init__.py +++ b/coldfront/core/project/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/project/models.py b/coldfront/core/project/models.py index 45f9470ac4..7a01e4b515 100644 --- a/coldfront/core/project/models.py +++ b/coldfront/core/project/models.py @@ -1,37 +1,45 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime -import textwrap from enum import Enum +from django.contrib.auth import get_user_model from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models -from ast import literal_eval -from coldfront.core.utils.validate import AttributeValidator +from django.urls import reverse from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords from coldfront.core.field_of_science.models import FieldOfScience +from coldfront.core.project.signals import project_activate_user, project_remove_user from coldfront.core.utils.common import import_from_settings +from coldfront.core.utils.validate import AttributeValidator + +PROJECT_ENABLE_PROJECT_REVIEW = import_from_settings("PROJECT_ENABLE_PROJECT_REVIEW", False) -PROJECT_ENABLE_PROJECT_REVIEW = import_from_settings('PROJECT_ENABLE_PROJECT_REVIEW', False) class ProjectPermission(Enum): - """ A project permission stores the user, manager, pi, and update fields of a project. """ + """A project permission stores the user, manager, pi, and update fields of a project.""" + + USER = "user" + MANAGER = "manager" + PI = "pi" + UPDATE = "update" - USER = 'user' - MANAGER = 'manager' - PI = 'pi' - UPDATE = 'update' class ProjectStatusChoice(TimeStampedModel): - """ A project status choice indicates the status of the project. Examples include Active, Archived, and New. - + """A project status choice indicates the status of the project. Examples include Active, Archived, and New. + Attributes: name (str): name of project status choice """ + class Meta: - ordering = ('name',) + ordering = ("name",) class ProjectStatusChoiceManager(models.Manager): def get_by_natural_key(self, name): @@ -46,9 +54,10 @@ def __str__(self): def natural_key(self): return (self.name,) + class Project(TimeStampedModel): - """ A project is a container that includes users, allocations, publications, grants, and other research output. - + """A project is a container that includes users, allocations, publications, grants, and other research output. + Attributes: title (str): name of the project pi (User): represents the User object of the project's PI @@ -58,9 +67,10 @@ class Project(TimeStampedModel): force_review (bool): indicates whether or not to force a review for the project requires_review (bool): indicates whether or not the project requires review """ + class Meta: - ordering = ['title'] - unique_together = ('title', 'pi') + ordering = ["title"] + unique_together = ("title", "pi") permissions = ( ("can_view_all_projects", "Can view all projects"), @@ -71,19 +81,23 @@ class ProjectManager(models.Manager): def get_by_natural_key(self, title, pi_username): return self.get(title=title, pi__username=pi_username) - - DEFAULT_DESCRIPTION = ''' + DEFAULT_DESCRIPTION = """ We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you! - ''' + """ - title = models.CharField(max_length=255,) - pi = models.ForeignKey(User, on_delete=models.CASCADE,) + title = models.CharField( + max_length=255, + ) + pi = models.ForeignKey( + User, + on_delete=models.CASCADE, + ) description = models.TextField( default=DEFAULT_DESCRIPTION, validators=[ MinLengthValidator( 10, - 'The project description must be > 10 characters.', + "The project description must be > 10 characters.", ) ], ) @@ -94,15 +108,22 @@ def get_by_natural_key(self, title, pi_username): requires_review = models.BooleanField(default=True) history = HistoricalRecords() objects = ProjectManager() + project_code = models.CharField(max_length=10, blank=True) + institution = models.CharField(max_length=80, blank=True, default="None") def clean(self): - """ Validates the project and raises errors if the project is invalid. """ + """Validates the project and raises errors if the project is invalid.""" - if 'Auto-Import Project'.lower() in self.title.lower(): - raise ValidationError('You must update the project title. You cannot have "Auto-Import Project" in the title.') + if "Auto-Import Project".lower() in self.title.lower(): + raise ValidationError( + 'You must update the project title. You cannot have "Auto-Import Project" in the title.' + ) - if 'We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!' in self.description: - raise ValidationError('You must update the project description.') + if ( + "We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!" + in self.description + ): + raise ValidationError("You must update the project description.") @property def last_project_review(self): @@ -110,9 +131,9 @@ def last_project_review(self): Returns: ProjectReview: the last project review that was created for this project """ - - if self.projectreview_set.exists(): - return self.projectreview_set.order_by('-created')[0] + project_review_query = self.projectreview_set.order_by("-created") + if project_review_query: + return project_review_query.first() else: return None @@ -122,9 +143,9 @@ def latest_grant(self): Returns: Grant: the most recent grant for this project, or None if there are no grants """ - - if self.grant_set.exists(): - return self.grant_set.order_by('-modified')[0] + grant_query = self.grant_set.order_by("-modified") + if grant_query: + return grant_query.first() else: return None @@ -134,9 +155,9 @@ def latest_publication(self): Returns: Publication: the most recent publication for this project, or None if there are no publications """ - - if self.publication_set.exists(): - return self.publication_set.order_by('-created')[0] + publication_query = self.publication_set.order_by("-created") + if publication_query: + return publication_query.first() else: return None @@ -147,7 +168,7 @@ def needs_review(self): bool: whether or not the project needs review """ - if self.status.name == 'Archived': + if self.status.name == "Archived": return False now = datetime.datetime.now(datetime.timezone.utc) @@ -162,7 +183,7 @@ def needs_review(self): return False if self.projectreview_set.exists(): - last_review = self.projectreview_set.order_by('-created')[0] + last_review = self.projectreview_set.order_by("-created")[0] last_review_over_365_days = (now - last_review.created).days > 365 else: last_review = None @@ -189,13 +210,13 @@ def user_permissions(self, user): if user.is_superuser: return list(ProjectPermission) - user_conditions = (models.Q(status__name__in=('Active', 'New')) & models.Q(user=user)) + user_conditions = models.Q(status__name__in=("Active", "New")) & models.Q(user=user) if not self.projectuser_set.filter(user_conditions).exists(): return [] permissions = [ProjectPermission.USER] - if self.projectuser_set.filter(user_conditions & models.Q(role__name='Manager')).exists(): + if self.projectuser_set.filter(user_conditions & models.Q(role__name="Manager")).exists(): permissions.append(ProjectPermission.MANAGER) if self.projectuser_set.filter(user_conditions & models.Q(project__pi_id=user.id)).exists(): @@ -225,9 +246,94 @@ def __str__(self): def natural_key(self): return (self.title,) + self.pi.natural_key() + def add_user(self, user, role_choice, signal_sender=None): + """ + Adds a user to the project. + + If a ProjectUser already exists, its role will be set to "Active" and its role updated. + Otherwise, creates a new ProjectUser. + + Params: + user (User): User to add. + role_choice (ProjetUserRoleChoice): Role to give the project user. + signal_sender (str): Sender for the `project_activate_user` signal. + """ + user_status_obj = ProjectUserStatusChoice.objects.get(name="Active") + + project_user, _created = self.projectuser_set.update_or_create( + user=user, + defaults={ + "status": user_status_obj, + "role": role_choice, + }, + ) + + project_activate_user.send(sender=signal_sender, project_user_pk=project_user.pk) + + def remove_user(self, user, signal_sender=None): + """ + Marks a `ProjectUser` and any associated `AllocationUser`s as 'Removed'. + + Params: + user (User|ProjectUser): User to remove. + signal_sender (str): Sender for the `project_remove_user` and `allocation_remove_user` signals. + + Raises: + ProjectUser.DoesNotExist: If `user` is a `User` and that user is not found in the Project. + + """ + if isinstance(user, ProjectUser): + project_user = user + elif isinstance(user, get_user_model()): + project_user = self.projectuser_set.get(user=user) + + for active_allocation in self.allocation_set.filter( + status__name__in=( + "Active", + "Denied", + "New", + "Paid", + "Payment Pending", + "Payment Requested", + "Payment Declined", + "Renewal Requested", + "Unpaid", + ) + ): + active_allocation.remove_user(project_user.user, signal_sender) + + project_user.status = ProjectUserStatusChoice.objects.get(name="Removed") + project_user.save() + project_remove_user.send(sender=signal_sender, project_user_pk=project_user.pk) + + def get_absolute_url(self): + return reverse("project-detail", kwargs={"pk": self.pk}) + + def get_user_emails(self, ignore_disabled_notifications=False) -> set[str]: + """Gets a set of user emails for notifications. + + Params: + ignore_disabled_notifications (bool): If True, include project users + that have enable_notifications off. + + Returns: + set: A set of user emails for notifications. + """ + filter_options = { + "status__name": "Active", + } + if not ignore_disabled_notifications: + filter_options["enable_notifications"] = True + + project_users = self.projectuser_set.filter(**filter_options) + user_emails = set(project_users.values_list("user__email", flat=True)) + user_emails.add(self.pi.email) + return user_emails + + class ProjectAdminComment(TimeStampedModel): - """ A project admin comment is a comment that an admin can make on a project. - + """A project admin comment is a comment that an admin can make on a project. + Attributes: project (Project): links the project the comment is from to the comment author (User): represents the admin who authored the comment @@ -241,9 +347,10 @@ class ProjectAdminComment(TimeStampedModel): def __str__(self): return self.comment + class ProjectUserMessage(TimeStampedModel): - """ A project user message is a message sent to a user in a project. - + """A project user message is a message sent to a user in a project. + Attributes: project (Project): links the project the message is from to the message author (User): represents the user who authored the message @@ -259,9 +366,10 @@ class ProjectUserMessage(TimeStampedModel): def __str__(self): return self.message + class ProjectReviewStatusChoice(TimeStampedModel): - """ A project review status choice is an option a user can choose when setting a project's status. Examples include Completed and Pending. - + """A project review status choice is an option a user can choose when setting a project's status. Examples include Completed and Pending. + Attributes: name (str): name of the status choice """ @@ -272,11 +380,14 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class ProjectReview(TimeStampedModel): - """ A project review is what a user submits to their PI when their project status is Pending. - + """A project review is what a user submits to their PI when their project status is Pending. + Attributes: project (Project): links the project to its review status (ProjectReviewStatusChoice): links the project review to its status @@ -284,19 +395,22 @@ class ProjectReview(TimeStampedModel): """ project = models.ForeignKey(Project, on_delete=models.CASCADE) - status = models.ForeignKey(ProjectReviewStatusChoice, on_delete=models.CASCADE, verbose_name='Status') + status = models.ForeignKey(ProjectReviewStatusChoice, on_delete=models.CASCADE, verbose_name="Status") reason_for_not_updating_project = models.TextField(blank=True, null=True) history = HistoricalRecords() + class ProjectUserRoleChoice(TimeStampedModel): - """ A project user role choice is an option a PI, manager, or admin has while selecting a user's role. Examples include Manager and User. - + """A project user role choice is an option a PI, manager, or admin has while selecting a user's role. Examples include Manager and User. + Attributes: - name (str): name of the user role choice + name (str): name of the user role choice """ class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class ProjectUserRoleChoiceManager(models.Manager): def get_by_natural_key(self, name): @@ -311,14 +425,18 @@ def __str__(self): def natural_key(self): return (self.name,) + class ProjectUserStatusChoice(TimeStampedModel): - """ A project user status choice indicates the status of a project user. Examples include Active, Pending, and Denied. - + """A project user status choice indicates the status of a project user. Examples include Active, Pending, and Denied. + Attributes: name (str): name of the project user status choice """ + class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class ProjectUserStatusChoiceManager(models.Manager): def get_by_natural_key(self, name): @@ -333,9 +451,10 @@ def __str__(self): def natural_key(self): return (self.name,) + class ProjectUser(TimeStampedModel): - """ A project user represents a user on the project. - + """A project user represents a user on the project. + Attributes: user (User): represents the User object of the project user project (Project): links user to its project @@ -347,35 +466,39 @@ class ProjectUser(TimeStampedModel): user = models.ForeignKey(User, on_delete=models.CASCADE) project = models.ForeignKey(Project, on_delete=models.CASCADE) role = models.ForeignKey(ProjectUserRoleChoice, on_delete=models.CASCADE) - status = models.ForeignKey(ProjectUserStatusChoice, on_delete=models.CASCADE, verbose_name='Status') + status = models.ForeignKey(ProjectUserStatusChoice, on_delete=models.CASCADE, verbose_name="Status") enable_notifications = models.BooleanField(default=True) history = HistoricalRecords() def __str__(self): - return '%s %s (%s)' % (self.user.first_name, self.user.last_name, self.user.username) + return "%s %s (%s)" % (self.user.first_name, self.user.last_name, self.user.username) class Meta: - unique_together = ('user', 'project') + unique_together = ("user", "project") verbose_name_plural = "Project User Status" + class AttributeType(TimeStampedModel): - """ An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. - + """An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. + Attributes: name (str): name of attribute data type """ - + name = models.CharField(max_length=64) def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class ProjectAttributeType(TimeStampedModel): - """ A project attribute type indicates the type of the attribute. Examples include Project ID and Account Number. - + """A project attribute type indicates the type of the attribute. Examples include Project ID and Account Number. + Attributes: attribute_type (AttributeType): indicates the data type of the attribute name (str): name of project attribute type @@ -396,17 +519,20 @@ class ProjectAttributeType(TimeStampedModel): history = HistoricalRecords() def __str__(self): - return '%s (%s)' % (self.name, self.attribute_type.name) + return "%s (%s)" % (self.name, self.attribute_type.name) def __repr__(self) -> str: return str(self) class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class ProjectAttribute(TimeStampedModel): - """ A project attribute class links a project attribute type and a project. - + """A project attribute class links a project attribute type and a project. + Attributes: proj_attr_type (ProjectAttributeType): project attribute type to link project (Project): project to link @@ -420,17 +546,20 @@ class ProjectAttribute(TimeStampedModel): history = HistoricalRecords() def save(self, *args, **kwargs): - """ Saves the project attribute. """ + """Saves the project attribute.""" super().save(*args, **kwargs) if self.proj_attr_type.has_usage and not ProjectAttributeUsage.objects.filter(project_attribute=self).exists(): - ProjectAttributeUsage.objects.create( - project_attribute=self) + ProjectAttributeUsage.objects.create(project_attribute=self) def clean(self): - """ Validates the project and raises errors if the project is invalid. """ - if self.proj_attr_type.is_unique and self.project.projectattribute_set.filter(proj_attr_type=self.proj_attr_type).exists(): - raise ValidationError("'{}' attribute already exists for this project.".format( - self.proj_attr_type)) + """Validates the project and raises errors if the project is invalid.""" + if ( + self.proj_attr_type.is_unique + and self.project.projectattribute_set.filter(proj_attr_type=self.proj_attr_type) + .exclude(pk=self.pk) + .exists() + ): + raise ValidationError("'{}' attribute already exists for this project.".format(self.proj_attr_type)) expected_value_type = self.proj_attr_type.attribute_type.name.strip() @@ -446,20 +575,20 @@ def clean(self): validator.validate_date() def __str__(self): - return '%s' % (self.proj_attr_type.name) + return "%s" % (self.proj_attr_type.name) + class ProjectAttributeUsage(TimeStampedModel): - """ Project attribute usage indicates the usage of a project attribute. - + """Project attribute usage indicates the usage of a project attribute. + Attributes: project_attribute (ProjectAttribute): links the usage to its project attribute value (float): usage value of the project attribute """ - project_attribute = models.OneToOneField( - ProjectAttribute, on_delete=models.CASCADE, primary_key=True) + project_attribute = models.OneToOneField(ProjectAttribute, on_delete=models.CASCADE, primary_key=True) value = models.FloatField(default=0) history = HistoricalRecords() def __str__(self): - return '{}: {}'.format(self.project_attribute.proj_attr_type.name, self.value) + return "{}: {}".format(self.project_attribute.proj_attr_type.name, self.value) diff --git a/coldfront/core/project/signals.py b/coldfront/core/project/signals.py new file mode 100644 index 0000000000..fc4b0c5c87 --- /dev/null +++ b/coldfront/core/project/signals.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import django.dispatch + +project_new = django.dispatch.Signal() +# providing_args=["project_obj"] + +project_archive = django.dispatch.Signal() +# providing_args=["project_obj"] + +project_update = django.dispatch.Signal() +# providing_args=["project_obj"] + +project_activate_user = django.dispatch.Signal() +# providing_args=["project_user_pk"] + +project_remove_user = django.dispatch.Signal() +# providing_args=["project_user_pk"] diff --git a/coldfront/core/project/templates/project/add_user_search_results.html b/coldfront/core/project/templates/project/add_user_search_results.html index 075dde373e..defa842e18 100644 --- a/coldfront/core/project/templates/project/add_user_search_results.html +++ b/coldfront/core/project/templates/project/add_user_search_results.html @@ -7,7 +7,7 @@ Found {{number_of_usernames_found}} of {{number_of_usernames_searched}} usernames searched. {% elif matches %} - Found {{matches|length}} match{{matches|length|pluralize}}. + Found {{matches|length}} match{{matches|length|pluralize:"es"}}. {% endif %}
{% if usernames_not_found %} @@ -26,15 +26,45 @@
Available Allocations
- {{allocation_form|crispy}} +
+ + + + + + + + + + + + + {% for form in allocation_formset %} + + + + + + + + + {% endfor %} + +
#ResourceDetailsResource TypeStatus
{{ form.selected }}{{ form.pk.value }}{{ form.resource.value }}{{ form.details.value }}{{ form.resource_type.value }}{{ form.status.value }}
+
+ + {{ allocation_formset.management_form }}
- + @@ -87,14 +117,16 @@ } }); - $("#id_allocationform-allocation_0").click(function () { + $("#selectAllAllocations").click(function () { $("input[name^='allocationform-']").prop('checked', $(this).prop('checked')); }); $("input[name^='allocationform-']").click(function (ele) { var id = $(this).attr('id'); - if ( id != "id_allocationform-allocation_0") { - $("#id_allocationform-allocation_0").prop('checked', false); + if ( id != "selectAllAllocations") { + $("#selectAllAllocations").prop('checked', false); } }); + +{% comment %} if eula box is in focus, then required {% endcomment %} diff --git a/coldfront/core/project/templates/project/project_add_users.html b/coldfront/core/project/templates/project/project_add_users.html index 77b234c565..cfae4212e7 100644 --- a/coldfront/core/project/templates/project/project_add_users.html +++ b/coldfront/core/project/templates/project/project_add_users.html @@ -14,10 +14,12 @@

Add users to project: {{project.title}}

-
+ {% csrf_token %} {{ user_search_form|crispy }} - +
+ +
@@ -44,11 +46,9 @@

Add users to project: {{project.title}}

{% block javascript %} - {{ block.super }} +{{ block.super }} {% endblock %} diff --git a/coldfront/core/project/templates/project/project_archive.html b/coldfront/core/project/templates/project/project_archive.html index 71ca648671..ce40fbce02 100644 --- a/coldfront/core/project/templates/project/project_archive.html +++ b/coldfront/core/project/templates/project/project_archive.html @@ -19,11 +19,11 @@

{{ project.pi.first_name }} {{ project.pi.last_name }} ({{ project.pi.username }}) - Email + Email

-

Description: {{ project.description }}

-

Field of Science: {{ project.field_of_science }}

-

Status: {{ project.status}}

+

Description: {{ project.description }}

+

Field of Science: {{ project.field_of_science }}

+

Status: {{ project.status}}

diff --git a/coldfront/core/project/templates/project/project_archived_list.html b/coldfront/core/project/templates/project/project_archived_list.html index cac6013a86..08538e6d4e 100644 --- a/coldfront/core/project/templates/project/project_archived_list.html +++ b/coldfront/core/project/templates/project/project_archived_list.html @@ -10,9 +10,9 @@ {% block content %} -
+
- @@ -25,12 +25,12 @@

Archived Projects

-
+
{{ project_search_form|crispy }} @@ -52,42 +52,49 @@

Archived Projects

{% for project in project_list %} - + {% if project.project_code %} + + {% else %} + + {% endif %} - + {% if settings.PROJECT_INSTITUTION_EMAIL_MAP %} +

Institution: {{ project.institution }}

+ {% endif %} {% endfor %}
# Username First Name
ID - Sort ID asc - Sort ID desc + Sort ID asc + Sort ID desc PI - Sort PI asc - Sort PI desc + Sort PI asc + Sort PI desc Title and Description Field of Science - Sort Field of Science asc - Sort Field of Science desc + Sort Field of Science asc + Sort Field of Science desc Status - Sort Status asc - Sort Status desc + Sort Status asc + Sort Status desc
{{ project.id }}{{ project.project_code }}{{ project.id }}{{ project.pi.username }}Title: {{ project.title }} + Title: {{ project.title }}
Description: {{ project.description }}
{{ project.field_of_science.description }} {{ project.status.name }}
{% if is_paginated %} Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} -
    +
      {% if page_obj.has_previous %}
    • Previous
    • {% else %} @@ -107,19 +114,4 @@

      Archived Projects

{% endif %} - {% endblock %} diff --git a/coldfront/core/project/templates/project/project_attribute_create.html b/coldfront/core/project/templates/project/project_attribute_create.html index 39c7beb439..96c3cc0b7f 100644 --- a/coldfront/core/project/templates/project/project_attribute_create.html +++ b/coldfront/core/project/templates/project/project_attribute_create.html @@ -1,22 +1,22 @@ -{% extends "common/base.html" %} -{% load crispy_forms_tags %} -{% load common_tags %} -{% load static %} - - -{% block title %} -Add Project Attribute -{% endblock %} - - -{% block content %} -

Adding project attribute to {{ project }}

- - - {% csrf_token %} - {{ form | crispy }} - - Back to - Project
- +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load common_tags %} +{% load static %} + + +{% block title %} +Add Project Attribute +{% endblock %} + + +{% block content %} +

Adding project attribute to {{ project }}

+ +
+ {% csrf_token %} + {{ form | crispy }} + + Back to + Project
+
{% endblock %} \ No newline at end of file diff --git a/coldfront/core/project/templates/project/project_attribute_delete.html b/coldfront/core/project/templates/project/project_attribute_delete.html index f5b9f372ed..c996f8b1f8 100644 --- a/coldfront/core/project/templates/project/project_attribute_delete.html +++ b/coldfront/core/project/templates/project/project_attribute_delete.html @@ -1,73 +1,61 @@ -{% extends "common/base.html" %} -{% load crispy_forms_tags %} -{% load common_tags %} -{% load static %} - - -{% block title %} -Remove Project Attribute -{% endblock %} - - -{% block content %} -

Removing project attribute from {{ project }}

- -{% if formset %} -
-
-
- {% csrf_token %} -
- - - - - - - - - - {% for form in formset %} - - - - - - {% endfor %} - -
- - TypeValue
{{ form.selected }}{{ form.name.value }}{{ form.value.value }}
-
- {{ formset.management_form }} -
- - Back to Project -
-
-
-
-
-{% else %} -
- No attributes to remove! -
- Back to Project -{% endif %} - - -{% endblock %} \ No newline at end of file +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load common_tags %} +{% load static %} + + +{% block title %} +Remove Project Attribute +{% endblock %} + + +{% block content %} +

Removing project attribute from {{ project }}

+ +{% if formset %} +
+
+
+ {% csrf_token %} +
+ + + + + + + + + + {% for form in formset %} + + + + + + {% endfor %} + +
+ + TypeValue
{{ form.selected }}{{ form.name.value }}{{ form.value.value }}
+
+ {{ formset.management_form }} +
+ + Back to Project +
+
+
+
+
+{% else %} +
+ No attributes to remove! +
+ Back to Project +{% endif %} + +{% endblock %} diff --git a/coldfront/core/project/templates/project/project_attribute_update.html b/coldfront/core/project/templates/project/project_attribute_update.html index 9d8213ebb6..a68a24a5d0 100644 --- a/coldfront/core/project/templates/project/project_attribute_update.html +++ b/coldfront/core/project/templates/project/project_attribute_update.html @@ -1,49 +1,48 @@ -{% extends "common/base.html" %} -{% load crispy_forms_tags %} -{% load static %} - - -{% block title %} -Project Attribute Details -{% endblock %} - - -{% block content %} -

Project Attribute Update Form

- -
- -
- {% csrf_token %} -
-
Attribute Details
-
-
- - - - - - - - - - - - - - - - - - -
Name:{{project_attribute_obj}}
Type:{{project_attribute_obj.proj_attr_type.attribute_type}}
Value:{{project_attribute_obj.value}}
New Value:{{project_attribute_update_form.new_value}}
-
-
- -
-
-{% endblock%} \ No newline at end of file +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load static %} + + +{% block title %} +Project Attribute Details +{% endblock %} + + +{% block content %} +

Project Attribute Update Form

+ +
+ +
+ {% csrf_token %} +
+
Attribute Details
+
+
+ + + + + + + + + + + + + + + + + +
Name:{{project_attribute_obj}}
Type:{{project_attribute_obj.proj_attr_type.attribute_type}}
Value:{{project_attribute_obj.value}}
New Value:{{project_attribute_update_form.new_value}}
+
+
+ +
+
+{% endblock%} diff --git a/coldfront/core/project/templates/project/project_create_form.html b/coldfront/core/project/templates/project/project_create_form.html index b7c115e972..1b35159aaf 100644 --- a/coldfront/core/project/templates/project/project_create_form.html +++ b/coldfront/core/project/templates/project/project_create_form.html @@ -1,4 +1,4 @@ -{% extends "common/base.html" %} +{% extends "common/base.html" %} {% load crispy_forms_tags %} {% load static %} @@ -16,9 +16,4 @@ Cancel - {% endblock %} diff --git a/coldfront/core/project/templates/project/project_detail.html b/coldfront/core/project/templates/project/project_detail.html index 9f0f39657c..b0fc4f76c6 100644 --- a/coldfront/core/project/templates/project/project_detail.html +++ b/coldfront/core/project/templates/project/project_detail.html @@ -2,6 +2,7 @@ {% load crispy_forms_tags %} {% load humanize %} {% load static %} +{% load common_tags %} {% block title %} @@ -26,15 +27,15 @@ {% endif %}
-

{{ project.title }}

+

{{ project.title }}


{% if project.status.name != 'Archived' and is_allowed_to_update_project %} -
+

Manage Project

-
+
{% if project.status.name in 'Active, New' %} Update Project Information Archive Project @@ -66,16 +67,24 @@

{{ project.pi.first_name }} {{ project.pi.last_name }} ({{ project.pi.username }}) - Email PI + Email PI

-

Description: {{ project.description }}

-

Field of Science: {{ project.field_of_science }}

-

Project Status: {{ project.status }} +

Description: {{ project.description }}

+ {% if project.project_code %} +

Project Code: {{ project.project_code }}

+ {% else %} +

ID: {{ project.id }}

+ {% endif %} +

Field of Science: {{ project.field_of_science }}

+

Project Status: {{ project.status }} {% if project.last_project_review and project.last_project_review.status.name == 'Pending'%} - project review pending + project review pending {% endif %}

-

Created: {{ project.created|date:"M. d, Y" }}

+ {% if settings.PROJECT_INSTITUTION_EMAIL_MAP %} +

Institution: {{ project.institution }}

+ {% endif %} +

Created: {{ project.created|date:"M. d, Y" }}

@@ -84,8 +93,8 @@

-

Users

{{project_users.count}} -
+

Users

{{project_users.count}} +
{% if project.status.name != 'Archived' and is_allowed_to_update_project %} Email Project Users Add Users @@ -95,17 +104,17 @@

- +
- + - - + + @@ -147,7 +156,7 @@

@@ -163,8 +172,8 @@

-

Allocations

{{allocations.count}} -
+

Allocations

{{allocations.count}} +
{% if project.status.name != 'Archived' and is_allowed_to_update_project %} Request Resource Allocation {% endif %} @@ -173,7 +182,7 @@

Allocation
{% if allocations %}
-

Username First Name Last Name EmailRole Manager role grants user access to add/remove users, allocations, grants, and publications to the project.Role Manager role grants user access to add/remove users, allocations, grants, and publications to the project. StatusEnable Notifications When disabled, user will not receive notifications for allocation requests and expirations or cloud usage (if applicable).ActionsEnable Notifications When disabled, user will not receive notifications for allocation requests and expirations or cloud usage (if applicable).Actions
{% if is_allowed_to_update_project %} - Edit + Edit {% endif %}
+
@@ -181,21 +190,32 @@

Allocation

- + {% for allocation in allocations %} - - - {% if allocation.get_information != '' %} - + {% with allocation.get_parent_resource as parent_resource %} + + + {% if user_allocation_status|get_value_by_index:forloop.counter0 == 'PendingEULA' %} + + {% else %} + {% with allocation.get_information as allocation_information %} + {% if allocation_information != '' %} + {% else %} {% endif %} + {% endwith %} + {% endif %} {% if allocation.status.name == 'Active' %} + {% if user_allocation_status|get_value_by_index:forloop.counter0 == 'PendingEULA' %} + + {% else %} + {% endif %} {% elif allocation.status.name == 'Expired' or allocation.status.name == 'Denied' %} {% else %} @@ -203,21 +223,22 @@

Allocation {% endif %}

{% endfor %} @@ -234,8 +255,8 @@

Allocation
-

Attributes

{{attributes.count}} -
+

Attributes

{{attributes.count}} +
{% if project.status.name != 'Archived' and is_allowed_to_update_project %} Add Attribute {% if attributes %} @@ -253,7 +274,7 @@

Attri

{% if is_allowed_to_update_project %} - + {% endif %} @@ -263,7 +284,7 @@

Attri

- + {% else %} @@ -272,9 +293,9 @@

Attri {{attribute.value}} {% if is_allowed_to_update_project %} -

+ {% endif %} - + {% endif %} {% endfor %} @@ -286,7 +307,9 @@

Attri

{{attribute}}

-
+
+ +
{% endfor %} @@ -303,11 +326,13 @@

{{attribute}}

-

Grants

{{grants.count}} -
- {% if project.latest_grant.modified %} - Last Updated: {{project.latest_grant.modified|date:"M. d, Y"}} +

Grants

{{grants.count}} +
+ {% with project.latest_grant as latest_grant %} + {% if latest_grant.modified %} + Last Updated: {{latest_grant.modified|date:"M. d, Y"}} {% endif %} + {% endwith %} {% if project.status.name != 'Archived' and is_allowed_to_update_project %} Add Grant {% if grants %} @@ -319,7 +344,7 @@

{% if grants %}
-

Resource NameInformation Status End DateActionsActions
{{ allocation.get_parent_resource.name }}{{ allocation.get_parent_resource.resource_type.name }}{{allocation.get_information}}{{ parent_resource.name }}{{ parent_resource.resource_type.name }}Review and Accept EULA to activate {{allocation_information}}{{allocation.description|default_if_none:""}}{{ user_allocation_status|get_value_by_index:forloop.counter0 }}{{ allocation.status.name }}{{ allocation.status.name }}{{allocation.end_date|date:"Y-m-d"}} - Details + Details {% if allocation.is_locked and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} - + Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}}
Not renewable
- {% elif is_allowed_to_update_project and ALLOCATION_ENABLE_ALLOCATION_RENEWAL and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} + {% elif is_allowed_to_update_project and settings.ALLOCATION_ENABLE_ALLOCATION_RENEWAL and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} - + Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}}
Click to renew
{% endif %} - {% if allocation.get_parent_resource.get_ondemand_status == 'Yes' and ondemand_url %} - ondemand cta - {% endif %} + {% if parent_resource.get_ondemand_status == 'Yes' and ondemand_url %} + ondemand cta + {% endif %} + {% endwith %}
Attribute Type Attribute ValueActionsActions
{{attribute}} {{attribute.value}}EditEdit
EditEdit
+
@@ -329,7 +354,7 @@

Grant Start Date

- + @@ -348,7 +373,7 @@

{{ grant.status.name }} {% endif %} -

+ {% endfor %} @@ -367,11 +392,13 @@

-

Publications

{{publications.count}} -
- {% if project.latest_publication.created %} - Last Updated: {{project.latest_publication.created|date:"M. d, Y"}} +

Publications

{{publications.count}} +
+ {% with project.latest_publication as latest_publication %} + {% if latest_publication.created %} + Last Updated: {{latest_publication.created|date:"M. d, Y"}} {% endif %} + {% endwith %} {% if project.status.name != 'Archived' and is_allowed_to_update_project %} Add Publication {% if publications %} @@ -384,7 +411,7 @@

{% if publications %}
-

TitleGrant End Date StatusActionsActions
EditEdit
+
@@ -397,7 +424,7 @@

Visit source + Visit source {% endif %}
Author: {{ publication.author}}
Journal: {{ publication.journal}} @@ -421,8 +448,8 @@

-

Research Outputs

{{ research_outputs.count}} -
+

Research Outputs

{{ research_outputs.count}} +
{% if project.status.name != 'Archived' and is_allowed_to_update_project %} Add Research Output {% if research_outputs %} @@ -462,8 +489,8 @@

-

Notifications

{{project.projectusermessage_set.count}} -
+

Notifications

{{notes.count}} +
{% if request.user.is_superuser %} Add Notification @@ -472,9 +499,9 @@

Notificatio

- {% if project.projectusermessage_set.all %} + {% if notes %}
-

Title, Author, and Journal
+
@@ -483,7 +510,7 @@

Notificatio

- {% for message in project.projectusermessage_set.all %} + {% for message in notes %} {% if not message.is_private or request.user.is_superuser %} @@ -501,60 +528,12 @@

Notificatio +{% endblock %} +{% block javascript %} +{{ block.super }} + {% endblock %} diff --git a/coldfront/core/project/templates/project/project_list.html b/coldfront/core/project/templates/project/project_list.html index 90c6450c33..7c4c9c1150 100644 --- a/coldfront/core/project/templates/project/project_list.html +++ b/coldfront/core/project/templates/project/project_list.html @@ -10,11 +10,11 @@ {% block content %} -

+ {% if settings.PROJECT_INSTITUTION_EMAIL_MAP %} + + {% endif %} {% for project in project_list %} - + {% if project.project_code %} + + {% else %} + + {% endif %} - + + {% if settings.PROJECT_INSTITUTION_EMAIL_MAP %} + + {% endif %} {% endfor %}
Comment
{{ message.message }}
ID - Sort ID asc - Sort ID desc + Sort ID asc + Sort ID desc PI - Sort PI asc - Sort PI desc + Sort PI asc + Sort PI desc Title Field of Science - Sort Field of Science asc - Sort Field of Science desc + Sort Field of Science asc + Sort Field of Science desc Status - Sort Status asc - Sort Status desc + Sort Status asc + Sort Status desc + Institution + Sort Institution asc + Sort Institution desc +
{{ project.id }}{{ project.project_code }}{{ project.id }}{{ project.pi.username }}{{ project.title }}{{ project.title }} {{ project.field_of_science.description }} {{ project.status.name }}{{ project.institution }}
{% if is_paginated %} Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} -
    +
      {% if page_obj.has_previous %}
    • Previous
    • {% else %} @@ -110,23 +124,4 @@

      Projects

{% endif %} - {% endblock %} diff --git a/coldfront/core/project/templates/project/project_note_create.html b/coldfront/core/project/templates/project/project_note_create.html index 6b2c9bf937..42ea3aae18 100644 --- a/coldfront/core/project/templates/project/project_note_create.html +++ b/coldfront/core/project/templates/project/project_note_create.html @@ -1,23 +1,23 @@ -{% extends "common/base.html" %} -{% load crispy_forms_tags %} -{% load common_tags %} -{% load static %} - - -{% block title %} -Add Project Notification -{% endblock %} - - -{% block content %} -

Adding notification to {{project.title}} for PI {{ project.pi.first_name }} - {{ project.pi.last_name }} ({{ project.pi.username }})

- -
- {% csrf_token %} - {{form |crispy}} - - Back to - Project
-
+{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load common_tags %} +{% load static %} + + +{% block title %} +Add Project Notification +{% endblock %} + + +{% block content %} +

Adding notification to {{project.title}} for PI {{ project.pi.first_name }} + {{ project.pi.last_name }} ({{ project.pi.username }})

+ +
+ {% csrf_token %} + {{form |crispy}} + + Back to + Project
+
{% endblock %} \ No newline at end of file diff --git a/coldfront/core/project/templates/project/project_remove_users.html b/coldfront/core/project/templates/project/project_remove_users.html index 530d78f2d4..d525cd6d10 100644 --- a/coldfront/core/project/templates/project/project_remove_users.html +++ b/coldfront/core/project/templates/project/project_remove_users.html @@ -19,7 +19,7 @@

Remove users from project: {{project.title}}

- + # Username @@ -54,22 +54,10 @@

Remove users from project: {{project.title}}

{% else %} - Back to Project + Back to Project
No users to remove!
{% endif %} - {% endblock %} diff --git a/coldfront/core/project/templates/project/project_review.html b/coldfront/core/project/templates/project/project_review.html index cd27184bf1..9c0c18d960 100644 --- a/coldfront/core/project/templates/project/project_review.html +++ b/coldfront/core/project/templates/project/project_review.html @@ -14,9 +14,9 @@

Reviewing Project: {{project.title}}


-

{% settings_value 'CENTER_NAME' %} requires faculty to review their project information annually in order to renew their group’s accounts. The information provided by researchers is compiled and used to help make the case to the University for continued investment in {% settings_value 'CENTER_NAME' %}. Up-to-date and accurate information is crucial to our success. Questions? Contact us

+

{% settings_value 'CENTER_NAME' %} requires faculty to review their project information annually in order to renew their group’s accounts. The information provided by researchers is compiled and used to help make the case to the University for continued investment in {% settings_value 'CENTER_NAME' %}. Up-to-date and accurate information is crucial to our success. Questions? Contact us

-

Please update the following information:

+

Please update the following information:

  1. Verify your project description is accurate
  2. diff --git a/coldfront/core/project/templates/project/project_review_list.html b/coldfront/core/project/templates/project/project_review_list.html index 391667b9ae..e6f487db87 100644 --- a/coldfront/core/project/templates/project/project_review_list.html +++ b/coldfront/core/project/templates/project/project_review_list.html @@ -34,7 +34,7 @@

    Pending Project Reviews

    {% for project_review in project_review_list %} {{project_review.project.title|truncatechars:50}} - {{ project_review.created|date:"M. d, Y" }} + {{ project_review.created|date:"M. d, Y" }} {{project_review.project.pi.first_name}} {{project_review.project.pi.last_name}} ({{project_review.project.pi.username}}) {% if settings.GRANT_ENABLE %} {{ project_review.project.latest_grant.modified|date:"M. d, Y"|default:"No grants" }} @@ -44,8 +44,8 @@

    Pending Project Reviews

    {% endif %} {{ project_review.reason_for_not_updating_project}} - Mark Complete - Email + Mark Complete + Email {% endfor %} @@ -58,10 +58,4 @@

    Pending Project Reviews

{% endif %} - {% endblock %} diff --git a/coldfront/core/project/templates/project/project_update_form.html b/coldfront/core/project/templates/project/project_update_form.html index 159f54457c..d8156b2abe 100644 --- a/coldfront/core/project/templates/project/project_update_form.html +++ b/coldfront/core/project/templates/project/project_update_form.html @@ -16,9 +16,4 @@ Cancel - {% endblock %} diff --git a/coldfront/core/project/tests.py b/coldfront/core/project/tests.py deleted file mode 100644 index afc85f9af8..0000000000 --- a/coldfront/core/project/tests.py +++ /dev/null @@ -1,207 +0,0 @@ -import logging - -from django.core.exceptions import ValidationError -from django.test import TestCase - -from coldfront.core.test_helpers.factories import ( - UserFactory, - ProjectFactory, - FieldOfScienceFactory, - ProjectAttributeFactory, - ProjectStatusChoiceFactory, - ProjectAttributeTypeFactory, - PAttributeTypeFactory, -) -from coldfront.core.project.models import ( - Project, - ProjectAttribute, - ProjectAttributeType, -) - -logging.disable(logging.CRITICAL) - -class TestProject(TestCase): - - class Data: - """Collection of test data, separated for readability""" - - def __init__(self): - user = UserFactory(username='cgray') - user.userprofile.is_pi = True - - field_of_science = FieldOfScienceFactory(description='Chemistry') - status = ProjectStatusChoiceFactory(name='Active') - - self.initial_fields = { - 'pi': user, - 'title': 'Angular momentum in QGP holography', - 'description': 'We want to estimate the quark chemical potential of a rotating sample of plasma.', - 'field_of_science': field_of_science, - 'status': status, - 'force_review': True - } - - self.unsaved_object = Project(**self.initial_fields) - - def setUp(self): - self.data = self.Data() - - def test_fields_generic(self): - """Test that generic project fields save correctly""" - self.assertEqual(0, len(Project.objects.all())) - - project_obj = self.data.unsaved_object - project_obj.save() - - self.assertEqual(1, len(Project.objects.all())) - - retrieved_project = Project.objects.get(pk=project_obj.pk) - - for item in self.data.initial_fields.items(): - (field, initial_value) = item - with self.subTest(item=item): - saved_value = getattr(retrieved_project, field) - self.assertEqual(initial_value, saved_value) - self.assertEqual(project_obj, retrieved_project) - - def test_title_maxlength(self): - """Test that the title field has a maximum length of 255 characters""" - expected_maximum_length = 255 - maximum_title = 'x' * expected_maximum_length - - project_obj = self.data.unsaved_object - - project_obj.title = maximum_title + 'x' - with self.assertRaises(ValidationError): - project_obj.clean_fields() - - project_obj.title = maximum_title - project_obj.clean_fields() - project_obj.save() - - retrieved_obj = Project.objects.get(pk=project_obj.pk) - self.assertEqual(maximum_title, retrieved_obj.title) - - def test_auto_import_project_title(self): - """Test that auto-imported projects must have a title""" - project_obj = self.data.unsaved_object - assert project_obj.pk is None - - project_obj.title = 'Auto-Import Project' - with self.assertRaises(ValidationError): - project_obj.clean() - - def test_description_minlength(self): - """Test that a description must be at least 10 characters long - If description is less than 10 characters, an error should be raised - """ - expected_minimum_length = 10 - minimum_description = 'x' * expected_minimum_length - - project_obj = self.data.unsaved_object - - project_obj.description = minimum_description[:-1] - with self.assertRaises(ValidationError): - project_obj.clean_fields() - - project_obj.description = minimum_description - project_obj.clean_fields() - project_obj.save() - - retrieved_obj = Project.objects.get(pk=project_obj.pk) - self.assertEqual(minimum_description, retrieved_obj.description) - - def test_description_update_required_initially(self): - """ - Test that project descriptions must be changed from the default value. - """ - project_obj = self.data.unsaved_object - assert project_obj.pk is None - - project_obj.description = project_obj.DEFAULT_DESCRIPTION - with self.assertRaises(ValidationError): - project_obj.clean() - - def test_pi_foreignkey_on_delete(self): - """Test that a project is deleted when its PI is deleted.""" - project_obj = self.data.unsaved_object - project_obj.save() - - self.assertEqual(1, len(Project.objects.all())) - - project_obj.pi.delete() - - # expecting CASCADE - with self.assertRaises(Project.DoesNotExist): - Project.objects.get(pk=project_obj.pk) - self.assertEqual(0, len(Project.objects.all())) - - def test_fos_foreignkey_on_delete(self): - """Test that a project is deleted when its field of science is deleted. - """ - project_obj = self.data.unsaved_object - project_obj.save() - - self.assertEqual(1, len(Project.objects.all())) - - project_obj.field_of_science.delete() - - # expecting CASCADE - with self.assertRaises(Project.DoesNotExist): - Project.objects.get(pk=project_obj.pk) - self.assertEqual(0, len(Project.objects.all())) - - def test_status_foreignkey_on_delete(self): - """Test that a project is deleted when its status is deleted.""" - project_obj = self.data.unsaved_object - project_obj.save() - - self.assertEqual(1, len(Project.objects.all())) - - project_obj.status.delete() - - # expecting CASCADE - with self.assertRaises(Project.DoesNotExist): - Project.objects.get(pk=project_obj.pk) - self.assertEqual(0, len(Project.objects.all())) - - -class TestProjectAttribute(TestCase): - - @classmethod - def setUpTestData(cls): - project_attr_types = [('Project ID', 'Text'), ('Account Number', 'Int')] - for atype in project_attr_types: - ProjectAttributeTypeFactory( - name=atype[0], - attribute_type=PAttributeTypeFactory(name=atype[1]), - has_usage=False, - is_unique=True, - ) - cls.project = ProjectFactory() - cls.new_attr = ProjectAttributeFactory( - proj_attr_type=ProjectAttributeType.objects.get(name='Account Number'), - project=cls.project, - value=1243, - ) - - def test_unique_attrs_one_per_project(self): - """ - Test that only one attribute of the same attribute type can be - saved if the attribute type is unique - """ - self.assertEqual(1, len(self.project.projectattribute_set.all())) - proj_attr_type = ProjectAttributeType.objects.get(name='Account Number') - new_attr = ProjectAttribute(project=self.project, proj_attr_type=proj_attr_type) - with self.assertRaises(ValidationError): - new_attr.clean() - - def test_attribute_must_match_datatype(self): - """Test that the attribute value must match the attribute type""" - - proj_attr_type = ProjectAttributeType.objects.get(name='Account Number') - new_attr = ProjectAttribute( - project=self.project, proj_attr_type=proj_attr_type, value='abc' - ) - with self.assertRaises(ValidationError): - new_attr.clean() diff --git a/coldfront/core/project/tests/__init__.py b/coldfront/core/project/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/project/test_views.py b/coldfront/core/project/tests/test_views.py similarity index 58% rename from coldfront/core/project/test_views.py rename to coldfront/core/project/tests/test_views.py index aa2fd9bbf5..6a6aa236cb 100644 --- a/coldfront/core/project/test_views.py +++ b/coldfront/core/project/tests/test_views.py @@ -1,19 +1,27 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging from django.test import TestCase +from coldfront.core.project.models import ProjectUserStatusChoice from coldfront.core.test_helpers import utils from coldfront.core.test_helpers.factories import ( - UserFactory, - ProjectFactory, - ProjectUserFactory, + AllocationFactory, + AllocationStatusChoiceFactory, + AllocationUserFactory, + AllocationUserStatusChoiceFactory, PAttributeTypeFactory, ProjectAttributeFactory, - ProjectStatusChoiceFactory, ProjectAttributeTypeFactory, + ProjectFactory, + ProjectStatusChoiceFactory, + ProjectUserFactory, ProjectUserRoleChoiceFactory, + UserFactory, ) -from coldfront.core.project.models import ProjectUserStatusChoice logging.disable(logging.CRITICAL) @@ -24,24 +32,25 @@ class ProjectViewTestBase(TestCase): @classmethod def setUpTestData(cls): """Set up users and project for testing""" - cls.backend = 'django.contrib.auth.backends.ModelBackend' - cls.project = ProjectFactory(status=ProjectStatusChoiceFactory(name='Active')) + cls.backend = "django.contrib.auth.backends.ModelBackend" + cls.project = ProjectFactory(status=ProjectStatusChoiceFactory(name="Active")) - user_role = ProjectUserRoleChoiceFactory(name='User') - project_user = ProjectUserFactory(project=cls.project, role=user_role) - cls.project_user = project_user.user + user_role = ProjectUserRoleChoiceFactory(name="User") + cls.project_user = ProjectUserFactory(project=cls.project, role=user_role) - manager_role = ProjectUserRoleChoiceFactory(name='Manager') - pi_user = ProjectUserFactory( - project=cls.project, role=manager_role, user=cls.project.pi - ) - cls.pi_user = pi_user.user + manager_role = ProjectUserRoleChoiceFactory(name="Manager") + cls.pi_user = ProjectUserFactory(project=cls.project, role=manager_role, user=cls.project.pi) + cls.manager_user = ProjectUserFactory(project=cls.project, role=manager_role) cls.admin_user = UserFactory(is_staff=True, is_superuser=True) cls.nonproject_user = UserFactory(is_staff=False, is_superuser=False) - attributetype = PAttributeTypeFactory(name='string') + attributetype = PAttributeTypeFactory(name="Text") cls.projectattributetype = ProjectAttributeTypeFactory(attribute_type=attributetype) + cls.allocation = AllocationFactory(status=AllocationStatusChoiceFactory(name="active"), project=cls.project) + active_ausc = AllocationUserStatusChoiceFactory(name="Active") + cls.pi_as_alloc_user = AllocationUserFactory(allocation=cls.allocation, status=active_ausc) + def project_access_tstbase(self, url): """Test basic access control for project views. For all project views: - if not logged in, redirect to login page @@ -49,7 +58,7 @@ def project_access_tstbase(self, url): """ # If not logged in, can't see page; redirect to login page. utils.test_logged_out_redirect_to_login(self, url) - # after login, pi and admin can access create page + # If logged in as admin, can access page utils.test_user_can_access(self, self.admin_user, url) @@ -60,15 +69,15 @@ class ProjectDetailViewTest(ProjectViewTestBase): def setUpTestData(cls): """Set up users and project for testing""" super(ProjectDetailViewTest, cls).setUpTestData() - cls.url = f'/project/{cls.project.pk}/' + cls.url = f"/project/{cls.project.pk}/" def test_projectdetail_access(self): """Test project detail page access""" - # logged-out user gets redirected, admin can access create page + # logged-out user gets redirected, admin can access detail page self.project_access_tstbase(self.url) # pi and projectuser can access - utils.test_user_can_access(self, self.pi_user, self.url) - utils.test_user_can_access(self, self.project_user, self.url) + utils.test_user_can_access(self, self.pi_user.user, self.url) + utils.test_user_can_access(self, self.project_user.user, self.url) # user not belonging to project cannot access utils.test_user_cannot_access(self, self.nonproject_user, self.url) @@ -76,44 +85,47 @@ def test_projectdetail_permissions(self): """Test project detail page access permissions""" # admin has is_allowed_to_update_project set to True response = utils.login_and_get_page(self.client, self.admin_user, self.url) - self.assertEqual(response.context['is_allowed_to_update_project'], True) + self.assertEqual(response.context["is_allowed_to_update_project"], True) # pi has is_allowed_to_update_project set to True - response = utils.login_and_get_page(self.client, self.pi_user, self.url) - self.assertEqual(response.context['is_allowed_to_update_project'], True) + response = utils.login_and_get_page(self.client, self.pi_user.user, self.url) + self.assertEqual(response.context["is_allowed_to_update_project"], True) # non-manager user has is_allowed_to_update_project set to False - response = utils.login_and_get_page(self.client, self.project_user, self.url) - self.assertEqual(response.context['is_allowed_to_update_project'], False) + response = utils.login_and_get_page(self.client, self.project_user.user, self.url) + self.assertEqual(response.context["is_allowed_to_update_project"], False) def test_projectdetail_request_allocation_button_visibility(self): - """Test visibility of projectdetail request allocation button across user levels - """ - button_text = 'Request Resource Allocation' + """Test visibility of projectdetail request allocation button across user levels""" + button_text = "Request Resource Allocation" # admin can see request allocation button utils.page_contains_for_user(self, self.admin_user, self.url, button_text) # pi can see request allocation button - utils.page_contains_for_user(self, self.pi_user, self.url, button_text) + utils.page_contains_for_user(self, self.pi_user.user, self.url, button_text) # non-manager user cannot see request allocation button - utils.page_does_not_contain_for_user(self, self.project_user, self.url, button_text) + utils.page_does_not_contain_for_user(self, self.project_user.user, self.url, button_text) def test_projectdetail_edituser_button_visibility(self): - """Test visibility of projectdetail edit button across user levels - """ + """Test visibility of projectdetail edit button across user levels""" # admin can see edit button - utils.page_contains_for_user(self, self.admin_user, self.url, 'fa-user-edit') + utils.page_contains_for_user(self, self.admin_user, self.url, "fa-user-edit") # pi can see edit button - utils.page_contains_for_user(self, self.pi_user, self.url, 'fa-user-edit') + utils.page_contains_for_user(self, self.pi_user.user, self.url, "fa-user-edit") # non-manager user cannot see edit button - utils.page_does_not_contain_for_user(self, self.project_user, self.url, 'fa-user-edit') + utils.page_does_not_contain_for_user(self, self.project_user.user, self.url, "fa-user-edit") def test_projectdetail_addnotification_button_visibility(self): - """Test visibility of projectdetail add notification button across user levels - """ + """Test visibility of projectdetail add notification button across user levels""" # admin can see add notification button - utils.page_contains_for_user(self, self.admin_user, self.url, 'Add Notification') + utils.page_contains_for_user(self, self.admin_user, self.url, "Add Notification") # pi cannot see add notification button - utils.page_does_not_contain_for_user(self, self.pi_user, self.url, 'Add Notification') + utils.page_does_not_contain_for_user(self, self.pi_user.user, self.url, "Add Notification") # non-manager user cannot see add notification button - utils.page_does_not_contain_for_user(self, self.project_user, self.url, 'Add Notification') + utils.page_does_not_contain_for_user(self, self.project_user.user, self.url, "Add Notification") + + def test_manager_can_view_allocations(self): + """Project Manager should be able to view allocations on the project + without being a user on the Allocation""" + response = utils.login_and_get_page(self.client, self.manager_user.user, self.url) + self.assertEqual(len(response.context["allocations"]), 1) class ProjectCreateTest(ProjectViewTestBase): @@ -123,15 +135,15 @@ class ProjectCreateTest(ProjectViewTestBase): def setUpTestData(cls): """Set up users and project for testing""" super(ProjectCreateTest, cls).setUpTestData() - cls.url = '/project/create/' + cls.url = "/project/create/" def test_project_access(self): """Test access to project create page""" # logged-out user gets redirected, admin can access create page self.project_access_tstbase(self.url) # pi, projectuser and nonproject user cannot access create page - utils.test_user_cannot_access(self, self.pi_user, self.url) - utils.test_user_cannot_access(self, self.project_user, self.url) + utils.test_user_cannot_access(self, self.pi_user.user, self.url) + utils.test_user_cannot_access(self, self.project_user.user, self.url) utils.test_user_cannot_access(self, self.nonproject_user, self.url) @@ -142,61 +154,55 @@ class ProjectAttributeCreateTest(ProjectViewTestBase): def setUpTestData(cls): """Set up users and project for testing""" super(ProjectAttributeCreateTest, cls).setUpTestData() - int_attributetype = PAttributeTypeFactory(name='Int') + int_attributetype = PAttributeTypeFactory(name="Int") cls.int_projectattributetype = ProjectAttributeTypeFactory(attribute_type=int_attributetype) - cls.url = f'/project/{cls.project.pk}/project-attribute-create/' + cls.url = f"/project/{cls.project.pk}/project-attribute-create/" def test_project_access(self): """Test access to project attribute create page""" # logged-out user gets redirected, admin can access create page self.project_access_tstbase(self.url) # pi can access create page - utils.test_user_can_access(self, self.pi_user, self.url) + utils.test_user_can_access(self, self.pi_user.user, self.url) # project user and nonproject user cannot access create page - utils.test_user_cannot_access(self, self.project_user, self.url) + utils.test_user_cannot_access(self, self.project_user.user, self.url) utils.test_user_cannot_access(self, self.nonproject_user, self.url) def test_project_attribute_create_post(self): """Test project attribute creation post response""" self.client.force_login(self.admin_user, backend=self.backend) - response = self.client.post(self.url, data={ - 'proj_attr_type': self.projectattributetype.pk, - 'value': 'test_value', - 'project': self.project.pk - }) - redirect_url = f'/project/{self.project.pk}/' - self.assertRedirects( - response, redirect_url, status_code=302, target_status_code=200 + response = self.client.post( + self.url, + data={"proj_attr_type": self.projectattributetype.pk, "value": "test_value", "project": self.project.pk}, ) + redirect_url = f"/project/{self.project.pk}/" + self.assertRedirects(response, redirect_url, status_code=302, target_status_code=200) def test_project_attribute_create_post_required_values(self): """ProjectAttributeCreate correctly flags missing project or value""" self.client.force_login(self.admin_user, backend=self.backend) # missing project - response = self.client.post(self.url, data={ - 'proj_attr_type': self.projectattributetype.pk, 'value': 'test_value' - }) - self.assertFormError(response, 'form', 'project', 'This field is required.') + response = self.client.post( + self.url, data={"proj_attr_type": self.projectattributetype.pk, "value": "test_value"} + ) + self.assertIn(b"Adding project attribute to", response.content) # missing value - response = self.client.post(self.url, data={ - 'proj_attr_type': self.projectattributetype.pk, 'project': self.project.pk - }) - self.assertFormError(response, 'form', 'value', 'This field is required.') + response = self.client.post( + self.url, data={"proj_attr_type": self.projectattributetype.pk, "project": self.project.pk} + ) + self.assertIn(b"Adding project attribute to", response.content) def test_project_attribute_create_value_type_match(self): """ProjectAttributeCreate correctly flags value-type mismatch""" self.client.force_login(self.admin_user, backend=self.backend) # test that value must be numeric if proj_attr_type is string - response = self.client.post(self.url, data={ - 'proj_attr_type': self.int_projectattributetype.pk, - 'value': True, - 'project': self.project.pk - }) - self.assertFormError( - response, 'form', '', 'Invalid Value True. Value must be an int.' + response = self.client.post( + self.url, + data={"proj_attr_type": self.int_projectattributetype.pk, "value": True, "project": self.project.pk}, ) + self.assertContains(response, "Invalid Value True. Value must be an int.") class ProjectAttributeUpdateTest(ProjectViewTestBase): @@ -209,14 +215,15 @@ def setUpTestData(cls): cls.projectattribute = ProjectAttributeFactory( value=36238, proj_attr_type=cls.projectattributetype, project=cls.project ) - cls.url = f'/project/{cls.project.pk}/project-attribute-update/{cls.projectattribute.pk}' + cls.url = f"/project/{cls.project.pk}/project-attribute-update/{cls.projectattribute.pk}" def test_project_attribute_update_access(self): """Test access to project attribute update page""" self.project_access_tstbase(self.url) - utils.test_user_can_access(self, self.pi_user, self.url) - # project user, pi, and nonproject user cannot access update page - utils.test_user_cannot_access(self, self.project_user, self.url) + # pi can access project attribute update page + utils.test_user_can_access(self, self.pi_user.user, self.url) + # project user and nonproject user cannot access project attribute update page + utils.test_user_cannot_access(self, self.project_user.user, self.url) utils.test_user_cannot_access(self, self.nonproject_user, self.url) @@ -230,16 +237,16 @@ def setUpTestData(cls): cls.projectattribute = ProjectAttributeFactory( value=36238, proj_attr_type=cls.projectattributetype, project=cls.project ) - cls.url = f'/project/{cls.project.pk}/project-attribute-delete/' + cls.url = f"/project/{cls.project.pk}/project-attribute-delete/" def test_project_attribute_delete_access(self): """test access to project attribute delete page""" # logged-out user gets redirected, admin can access delete page self.project_access_tstbase(self.url) # pi can access delete page - utils.test_user_can_access(self, self.pi_user, self.url) + utils.test_user_can_access(self, self.pi_user.user, self.url) # project user and nonproject user cannot access delete page - utils.test_user_cannot_access(self, self.project_user, self.url) + utils.test_user_cannot_access(self, self.project_user.user, self.url) utils.test_user_cannot_access(self, self.nonproject_user, self.url) @@ -252,11 +259,9 @@ def setUpTestData(cls): super(ProjectListViewTest, cls).setUpTestData() # add 100 projects to test pagination, permissions, search functionality additional_projects = [ProjectFactory() for i in list(range(100))] - cls.additional_projects = [ - p for p in additional_projects - if p.pi.last_name != cls.project.pi.last_name - ] - cls.url = '/project/' + # cls.additional_projects = [p for p in additional_projects if p.pi.last_name != cls.project.pi.last_name] + cls.additional_projects = additional_projects + cls.url = "/project/" ### ProjectListView access tests ### @@ -265,8 +270,8 @@ def test_project_list_access(self): # logged-out user gets redirected, admin can access list page self.project_access_tstbase(self.url) # all other users can access list page - utils.test_user_can_access(self, self.pi_user, self.url) - utils.test_user_can_access(self, self.project_user, self.url) + utils.test_user_can_access(self, self.pi_user.user, self.url) + utils.test_user_can_access(self, self.project_user.user, self.url) utils.test_user_can_access(self, self.nonproject_user, self.url) ### ProjectListView display tests ### @@ -274,119 +279,154 @@ def test_project_list_access(self): def test_project_list_display_members(self): """Project list displays only projects that user is an active member of""" # deactivated projectuser won't see project on their page - response = utils.login_and_get_page(self.client, self.project_user, self.url) - self.assertEqual(len(response.context['object_list']), 1) - proj_user = self.project.projectuser_set.get(user=self.project_user) - proj_user.status, _ = ProjectUserStatusChoice.objects.get_or_create(name='Removed') + response = utils.login_and_get_page(self.client, self.project_user.user, self.url) + self.assertEqual(len(response.context["object_list"]), 1) + proj_user = self.project.projectuser_set.get(user=self.project_user.user) + proj_user.status, _ = ProjectUserStatusChoice.objects.get_or_create(name="Removed") proj_user.save() - response = utils.login_and_get_page(self.client, self.project_user, self.url) - self.assertEqual(len(response.context['object_list']), 0) + response = utils.login_and_get_page(self.client, self.project_user.user, self.url) + self.assertEqual(len(response.context["object_list"]), 0) def test_project_list_displayall_permission_admin(self): """Projectlist displayall option displays all projects to admin""" - url = self.url + '?show_all_projects=on' + url = self.url + "?show_all_projects=on" response = utils.login_and_get_page(self.client, self.admin_user, url) - self.assertGreaterEqual(101, len(response.context['object_list'])) + self.assertGreaterEqual(101, len(response.context["object_list"])) def test_project_list_displayall_permission_pi(self): """Projectlist displayall option displays only the pi's projects to the pi""" - url = self.url + '?show_all_projects=on' - response = utils.login_and_get_page(self.client, self.pi_user, url) - self.assertEqual(len(response.context['object_list']), 1) + url = self.url + "?show_all_projects=on" + response = utils.login_and_get_page(self.client, self.pi_user.user, url) + self.assertEqual(len(response.context["object_list"]), 1) def test_project_list_displayall_permission_project_user(self): - """Projectlist displayall displays only projects projectuser belongs to - """ - url = self.url + '?show_all_projects=on' - response = utils.login_and_get_page(self.client, self.project_user, url) - self.assertEqual(len(response.context['object_list']), 1) + """Projectlist displayall displays only projects projectuser belongs to""" + url = self.url + "?show_all_projects=on" + response = utils.login_and_get_page(self.client, self.project_user.user, url) + self.assertEqual(len(response.context["object_list"]), 1) ### ProjectListView search tests ### def test_project_list_search(self): """Test that project list search works.""" - url_base = self.url + '?show_all_projects=on' - url = ( - f'{url_base}&last_name={self.project.pi.last_name}' + - f'&field_of_science={self.project.field_of_science.description}' - ) + url_base = self.url + "?show_all_projects=on" + url = f"{url_base}&title={self.project.title}" # search by project project_title response = utils.login_and_get_page(self.client, self.admin_user, url) - self.assertEqual(len(response.context['object_list']), 1) + self.assertIn(self.project, response.context["object_list"]) class ProjectRemoveUsersViewTest(ProjectViewTestBase): """Tests for ProjectRemoveUsersView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/{self.project.pk}/remove-users/' + self.url = f"/project/{self.project.pk}/remove-users/" def test_projectremoveusersview_access(self): """test access to project remove users page""" self.project_access_tstbase(self.url) + # pi can access remove users page + utils.test_user_can_access(self, self.pi_user.user, self.url) + # project user and nonproject user cannot remove users page + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) class ProjectUpdateViewTest(ProjectViewTestBase): """Tests for ProjectUpdateView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/{self.project.pk}/update/' + self.url = f"/project/{self.project.pk}/update/" def test_projectupdateview_access(self): """test access to project update page""" self.project_access_tstbase(self.url) + # pi can access project update page + utils.test_user_can_access(self, self.pi_user.user, self.url) + # project user and nonproject user cannot access project update page + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) class ProjectReviewListViewTest(ProjectViewTestBase): """Tests for ProjectReviewListView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/project-review-list' + self.url = "/project/project-review-list" def test_projectreviewlistview_access(self): """test access to project review list page""" self.project_access_tstbase(self.url) + # pi, projectuser and nonproject user cannot access review list page + utils.test_user_cannot_access(self, self.pi_user.user, self.url) + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) class ProjectArchivedListViewTest(ProjectViewTestBase): """Tests for ProjectArchivedListView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/archived/' + self.url = "/project/archived/" def test_projectarchivedlistview_access(self): """test access to project archived list page""" self.project_access_tstbase(self.url) + # all other users can access archive list page + utils.test_user_can_access(self, self.pi_user.user, self.url) + utils.test_user_can_access(self, self.project_user.user, self.url) + utils.test_user_can_access(self, self.nonproject_user, self.url) class ProjectNoteCreateViewTest(ProjectViewTestBase): """Tests for ProjectNoteCreateView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/{self.project.pk}/projectnote/add' + self.url = f"/project/{self.project.pk}/projectnote/add" def test_projectnotecreateview_access(self): """test access to project note create page""" self.project_access_tstbase(self.url) + # pi, projectuser and nonproject user cannot access note create page + utils.test_user_cannot_access(self, self.pi_user.user, self.url) + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) class ProjectAddUsersSearchView(ProjectViewTestBase): """Tests for ProjectAddUsersSearchView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/{self.project.pk}/add-users-search/' + self.url = f"/project/{self.project.pk}/add-users-search/" def test_projectadduserssearchview_access(self): """test access to project add users search page""" self.project_access_tstbase(self.url) + # pi can access add users search page + utils.test_user_can_access(self, self.pi_user.user, self.url) + # project user and nonproject user cannot access add users search page + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) class ProjectUserDetailViewTest(ProjectViewTestBase): """Tests for ProjectUserDetailView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/{self.project.pk}/user-detail/{self.project_user.pk}' + self.url = f"/project/{self.project.pk}/user-detail/{self.project_user.pk}" def test_projectuserdetailview_access(self): """test access to project user detail page""" self.project_access_tstbase(self.url) + # pi can access user detail page + utils.test_user_can_access(self, self.pi_user.user, self.url) + # project user and nonproject user cannot access user detail page + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) diff --git a/coldfront/core/project/tests/tests.py b/coldfront/core/project/tests/tests.py new file mode 100644 index 0000000000..d428dc8036 --- /dev/null +++ b/coldfront/core/project/tests/tests.py @@ -0,0 +1,429 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging +import sys +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.test import TestCase, TransactionTestCase + +from coldfront.core.project.models import ( + Project, + ProjectAttribute, + ProjectAttributeType, +) +from coldfront.core.project.utils import ( + determine_automated_institution_choice, + generate_project_code, +) +from coldfront.core.test_helpers.factories import ( + FieldOfScienceFactory, + PAttributeTypeFactory, + ProjectAttributeFactory, + ProjectAttributeTypeFactory, + ProjectFactory, + ProjectStatusChoiceFactory, + UserFactory, +) + +logging.disable(logging.CRITICAL) + + +class TestProject(TestCase): + class Data: + """Collection of test data, separated for readability""" + + def __init__(self): + user = UserFactory(username="cgray") + user.userprofile.is_pi = True + + field_of_science = FieldOfScienceFactory(description="Chemistry") + status = ProjectStatusChoiceFactory(name="Active") + + self.initial_fields = { + "pi": user, + "title": "Angular momentum in QGP holography", + "description": "We want to estimate the quark chemical potential of a rotating sample of plasma.", + "field_of_science": field_of_science, + "status": status, + "force_review": True, + } + + self.unsaved_object = Project(**self.initial_fields) + + def setUp(self): + self.data = self.Data() + + def test_fields_generic(self): + """Test that generic project fields save correctly""" + self.assertEqual(0, len(Project.objects.all())) + + project_obj = self.data.unsaved_object + project_obj.save() + + self.assertEqual(1, len(Project.objects.all())) + + retrieved_project = Project.objects.get(pk=project_obj.pk) + + for item in self.data.initial_fields.items(): + (field, initial_value) = item + with self.subTest(item=item): + saved_value = getattr(retrieved_project, field) + self.assertEqual(initial_value, saved_value) + self.assertEqual(project_obj, retrieved_project) + + def test_title_maxlength(self): + """Test that the title field has a maximum length of 255 characters""" + expected_maximum_length = 255 + maximum_title = "x" * expected_maximum_length + + project_obj = self.data.unsaved_object + + project_obj.title = maximum_title + "x" + with self.assertRaises(ValidationError): + project_obj.clean_fields() + + project_obj.title = maximum_title + project_obj.clean_fields() + project_obj.save() + + retrieved_obj = Project.objects.get(pk=project_obj.pk) + self.assertEqual(maximum_title, retrieved_obj.title) + + def test_auto_import_project_title(self): + """Test that auto-imported projects must have a title""" + project_obj = self.data.unsaved_object + assert project_obj.pk is None + + project_obj.title = "Auto-Import Project" + with self.assertRaises(ValidationError): + project_obj.clean() + + def test_description_minlength(self): + """Test that a description must be at least 10 characters long + If description is less than 10 characters, an error should be raised + """ + expected_minimum_length = 10 + minimum_description = "x" * expected_minimum_length + + project_obj = self.data.unsaved_object + + project_obj.description = minimum_description[:-1] + with self.assertRaises(ValidationError): + project_obj.clean_fields() + + project_obj.description = minimum_description + project_obj.clean_fields() + project_obj.save() + + retrieved_obj = Project.objects.get(pk=project_obj.pk) + self.assertEqual(minimum_description, retrieved_obj.description) + + def test_description_update_required_initially(self): + """ + Test that project descriptions must be changed from the default value. + """ + project_obj = self.data.unsaved_object + assert project_obj.pk is None + + project_obj.description = project_obj.DEFAULT_DESCRIPTION + with self.assertRaises(ValidationError): + project_obj.clean() + + def test_pi_foreignkey_on_delete(self): + """Test that a project is deleted when its PI is deleted.""" + project_obj = self.data.unsaved_object + project_obj.save() + + self.assertEqual(1, len(Project.objects.all())) + + project_obj.pi.delete() + + # expecting CASCADE + with self.assertRaises(Project.DoesNotExist): + Project.objects.get(pk=project_obj.pk) + self.assertEqual(0, len(Project.objects.all())) + + def test_fos_foreignkey_on_delete(self): + """Test that a project is deleted when its field of science is deleted.""" + project_obj = self.data.unsaved_object + project_obj.save() + + self.assertEqual(1, len(Project.objects.all())) + + project_obj.field_of_science.delete() + + # expecting CASCADE + with self.assertRaises(Project.DoesNotExist): + Project.objects.get(pk=project_obj.pk) + self.assertEqual(0, len(Project.objects.all())) + + def test_status_foreignkey_on_delete(self): + """Test that a project is deleted when its status is deleted.""" + project_obj = self.data.unsaved_object + project_obj.save() + + self.assertEqual(1, len(Project.objects.all())) + + project_obj.status.delete() + + # expecting CASCADE + with self.assertRaises(Project.DoesNotExist): + Project.objects.get(pk=project_obj.pk) + self.assertEqual(0, len(Project.objects.all())) + + +class TestProjectAttribute(TestCase): + @classmethod + def setUpTestData(cls): + project_attr_types = [("Project ID", "Text"), ("Account Number", "Int")] + for atype in project_attr_types: + ProjectAttributeTypeFactory( + name=atype[0], + attribute_type=PAttributeTypeFactory(name=atype[1]), + has_usage=False, + is_unique=True, + ) + cls.project = ProjectFactory() + cls.new_attr = ProjectAttributeFactory( + proj_attr_type=ProjectAttributeType.objects.get(name="Account Number"), + project=cls.project, + value=1243, + ) + + def test_unique_attrs_one_per_project(self): + """ + Test that only one attribute of the same attribute type can be + saved if the attribute type is unique + """ + self.assertEqual(1, len(self.project.projectattribute_set.all())) + proj_attr_type = ProjectAttributeType.objects.get(name="Account Number") + new_attr = ProjectAttribute(project=self.project, proj_attr_type=proj_attr_type) + with self.assertRaises(ValidationError): + new_attr.clean() + + def test_attribute_must_match_datatype(self): + """Test that the attribute value must match the attribute type""" + + proj_attr_type = ProjectAttributeType.objects.get(name="Account Number") + new_attr = ProjectAttribute(project=self.project, proj_attr_type=proj_attr_type, value="abc") + with self.assertRaises(ValidationError): + new_attr.clean() + + +class TestProjectCode(TransactionTestCase): + """Tear down database after each run to prevent conflicts across cases""" + + reset_sequences = True + + def setUp(self): + self.user = UserFactory(username="capeo") + self.field_of_science = FieldOfScienceFactory(description="Physics") + self.status = ProjectStatusChoiceFactory(name="Active") + + def create_project_with_code(self, title, project_code, project_code_padding=0): + """Helper method to create a project and a project code with a specific prefix and padding""" + # Project Creation + project = Project.objects.create( + title=title, + pi=self.user, + status=self.status, + field_of_science=self.field_of_science, + ) + + project.project_code = generate_project_code(project_code, project.pk, project_code_padding) + + project.save() + + return project.project_code + + @patch("coldfront.config.core.PROJECT_CODE", "BFO") + @patch("coldfront.config.core.PROJECT_CODE_PADDING", 3) + def test_project_code_increment_after_deletion(self): + from coldfront.config.core import PROJECT_CODE, PROJECT_CODE_PADDING + + """Test that the project code increments by one after a project is deleted""" + + # Create the first project + project_with_code_padding1 = self.create_project_with_code("Project 1", PROJECT_CODE, PROJECT_CODE_PADDING) + self.assertEqual(project_with_code_padding1, "BFO001") + + # Delete the first project + project_obj1 = Project.objects.get(title="Project 1") + project_obj1.delete() + + # Create the second project + project_with_code_padding2 = self.create_project_with_code("Project 2", PROJECT_CODE, PROJECT_CODE_PADDING) + self.assertEqual(project_with_code_padding2, "BFO002") + + @patch("coldfront.config.core.PROJECT_CODE", "BFO") + def test_no_padding(self): + from coldfront.config.core import PROJECT_CODE + + """Test with code and no padding""" + project_with_code = self.create_project_with_code("Project 1", PROJECT_CODE) + self.assertEqual(project_with_code, "BFO1") # No padding + + @patch("coldfront.config.core.PROJECT_CODE", "BFO") + @patch("coldfront.config.core.PROJECT_CODE_PADDING", 3) + def test_different_prefix_padding(self): + from coldfront.config.core import PROJECT_CODE, PROJECT_CODE_PADDING + + """Test with code and padding""" + + # Create two projects with codes + project_with_code_padding1 = self.create_project_with_code("Project 1", PROJECT_CODE, PROJECT_CODE_PADDING) + project_with_code_padding2 = self.create_project_with_code("Project 2", PROJECT_CODE, PROJECT_CODE_PADDING) + + # Test the generated project codes + self.assertEqual(project_with_code_padding1, "BFO001") + self.assertEqual(project_with_code_padding2, "BFO002") + + +class TestInstitution(TestCase): + def setUp(self): + self.user = UserFactory(username="capeo") + self.field_of_science = FieldOfScienceFactory(description="Physics") + self.status = ProjectStatusChoiceFactory(name="Active") + + def create_project_with_institution(self, title, institution_dict=None): + """Helper method to create a project and assign a institution value based on the argument passed""" + # Project Creation + project = Project.objects.create( + title=title, + pi=self.user, + status=self.status, + field_of_science=self.field_of_science, + ) + + if institution_dict: + determine_automated_institution_choice(project, institution_dict) + + project.save() + + return project.institution + + @patch( + "coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP", + {"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"}, + ) + def test_institution_is_none(self): + from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP + + """Test to check if institution is none after both env vars are enabled. """ + + # Create project with both institution + project_institution = self.create_project_with_institution("Project 1", PROJECT_INSTITUTION_EMAIL_MAP) + + # Create the first project + self.assertEqual(project_institution, "None") + + @patch( + "coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP", + {"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"}, + ) + def test_institution_multiple_users(self): + from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP + + """Test to check multiple projects with different user email addresses, """ + + # Create project for user 1 + self.user.email = "user@inst.ac.com" + self.user.save() + project_institution_one = self.create_project_with_institution("Project 1", PROJECT_INSTITUTION_EMAIL_MAP) + self.assertEqual(project_institution_one, "AC") + + # Create project for user 2 + self.user.email = "user@bfo.ac.uk" + self.user.save() + project_institution_two = self.create_project_with_institution("Project 2", PROJECT_INSTITUTION_EMAIL_MAP) + self.assertEqual(project_institution_two, "BFO") + + # Create project for user 3 + self.user.email = "user@inst.edu.com" + self.user.save() + project_institution_three = self.create_project_with_institution("Project 3", PROJECT_INSTITUTION_EMAIL_MAP) + self.assertEqual(project_institution_three, "EDU") + + @patch( + "coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP", + {"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"}, + ) + def test_determine_automated_institution_choice_does_not_save_to_database(self): + from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP + + """Test that the function only modifies project in memory, not in database""" + + self.user.email = "user@inst.ac.com" + self.user.save() + + # Create project, similar to create_project_with_institution, but without the save function. + project = Project.objects.create( + title="Test Project", + pi=self.user, + status=self.status, + field_of_science=self.field_of_science, + institution="Default", + ) + + original_db_project = Project.objects.get(id=project.id) + self.assertEqual(original_db_project.institution, "Default") + + # Call the function and check object was modified in memory. + determine_automated_institution_choice(project, PROJECT_INSTITUTION_EMAIL_MAP) + self.assertEqual(project.institution, "AC") + + # Check that database was NOT modified + current_db_project = Project.objects.get(id=project.id) + self.assertEqual(original_db_project.institution, "Default") + + self.assertNotEqual(project.institution, current_db_project.institution) + + +class ProjectAttributeModelCleanMethodTests(TestCase): + def _test_clean(self, proj_attr_type_name: str, proj_attr_values: list, expect_validation_error: bool): + attribute_type = PAttributeTypeFactory(name=proj_attr_type_name) + proj_attr_type = ProjectAttributeTypeFactory(attribute_type=attribute_type) + project_attribute = ProjectAttributeFactory(proj_attr_type=proj_attr_type) + for value in proj_attr_values: + with self.subTest(value=value): + if not isinstance(value, str): + raise TypeError("project attribute value must be a string") + project_attribute.value = value + if expect_validation_error: + with self.assertRaises(ValidationError): + project_attribute.clean() + else: + project_attribute.clean() + + def test_expect_int_given_int(self): + self._test_clean("Int", ["-1", "0", "1", str(sys.maxsize)], False) + + def test_expect_int_given_float(self): + self._test_clean("Int", ["-1.0", "0.0", "1.0", "2e30"], True) + + def test_expect_int_given_garbage(self): + self._test_clean("Int", ["foobar", "", " ", "\0", "1j"], True) + + def test_expect_float_given_int(self): + self._test_clean("Float", ["-1", "0", "1", str(sys.maxsize)], False) + + def test_expect_float_given_float(self): + self._test_clean("Float", ["-1.0", "0.0", "1.0", "2e30"], False) + + def test_expect_float_given_garbage(self): + self._test_clean("Float", ["foobar", "", " ", "\0", "1j"], True) + + def test_expect_yes_no_given_yes_no(self): + self._test_clean("Yes/No", ["Yes", "No"], False) + + def test_expect_yes_no_given_garbage(self): + self._test_clean("Yes/No", ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j", "yes", "no", "YES", "NO"], True) + + def test_expect_date_given_date(self): + self._test_clean("Date", ["1970-01-01"], False) + + def test_expect_date_given_garbage(self): + self._test_clean("Date", ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j"], True) diff --git a/coldfront/core/project/urls.py b/coldfront/core/project/urls.py index 06a5fb67b4..1cf88e85c7 100644 --- a/coldfront/core/project/urls.py +++ b/coldfront/core/project/urls.py @@ -1,28 +1,60 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.project.views as project_views urlpatterns = [ - path('/', project_views.ProjectDetailView.as_view(), name='project-detail'), - path('/archive', project_views.ProjectArchiveProjectView.as_view(), name='project-archive'), - path('', project_views.ProjectListView.as_view(), name='project-list'), - path('project-user-update-email-notification/', project_views.project_update_email_notification, name='project-user-update-email-notification'), - path('archived/', project_views.ProjectArchivedListView.as_view(), name='project-archived-list'), - path('create/', project_views.ProjectCreateView.as_view(), name='project-create'), - path('/update/', project_views.ProjectUpdateView.as_view(), name='project-update'), - path('/add-users-search/', project_views.ProjectAddUsersSearchView.as_view(), name='project-add-users-search'), - path('/add-users-search-results/', project_views.ProjectAddUsersSearchResultsView.as_view(), name='project-add-users-search-results'), - path('/add-users/', project_views.ProjectAddUsersView.as_view(), name='project-add-users'), - path('/remove-users/', project_views.ProjectRemoveUsersView.as_view(), name='project-remove-users'), - path('/user-detail/', project_views.ProjectUserDetail.as_view(), name='project-user-detail'), - path('/review/', project_views.ProjectReviewView.as_view(), name='project-review'), - path('project-review-list', project_views.ProjectReviewListView.as_view(),name='project-review-list'), - path('project-review-complete//', project_views.ProjectReviewCompleteView.as_view(), - name='project-review-complete'), - path('project-review//email', project_views.ProjectReviewEmailView.as_view(), name='project-review-email'), - path('/projectnote/add', project_views.ProjectNoteCreateView.as_view(), name='project-note-add'), - path('/project-attribute-create/', project_views.ProjectAttributeCreateView.as_view(), name='project-attribute-create'), - path('/project-attribute-delete/', project_views.ProjectAttributeDeleteView.as_view(), name='project-attribute-delete'), - path('/project-attribute-update/', project_views.ProjectAttributeUpdateView.as_view(), name='project-attribute-update'), - + path("/", project_views.ProjectDetailView.as_view(), name="project-detail"), + path("/archive", project_views.ProjectArchiveProjectView.as_view(), name="project-archive"), + path("", project_views.ProjectListView.as_view(), name="project-list"), + path( + "project-user-update-email-notification/", + project_views.project_update_email_notification, + name="project-user-update-email-notification", + ), + path("archived/", project_views.ProjectArchivedListView.as_view(), name="project-archived-list"), + path("create/", project_views.ProjectCreateView.as_view(), name="project-create"), + path("/update/", project_views.ProjectUpdateView.as_view(), name="project-update"), + path( + "/add-users-search/", project_views.ProjectAddUsersSearchView.as_view(), name="project-add-users-search" + ), + path( + "/add-users-search-results/", + project_views.ProjectAddUsersSearchResultsView.as_view(), + name="project-add-users-search-results", + ), + path("/add-users/", project_views.ProjectAddUsersView.as_view(), name="project-add-users"), + path("/remove-users/", project_views.ProjectRemoveUsersView.as_view(), name="project-remove-users"), + path( + "/user-detail/", + project_views.ProjectUserDetail.as_view(), + name="project-user-detail", + ), + path("/review/", project_views.ProjectReviewView.as_view(), name="project-review"), + path("project-review-list", project_views.ProjectReviewListView.as_view(), name="project-review-list"), + path( + "project-review-complete//", + project_views.ProjectReviewCompleteView.as_view(), + name="project-review-complete", + ), + path("project-review//email", project_views.ProjectReviewEmailView.as_view(), name="project-review-email"), + path("/projectnote/add", project_views.ProjectNoteCreateView.as_view(), name="project-note-add"), + path( + "/project-attribute-create/", + project_views.ProjectAttributeCreateView.as_view(), + name="project-attribute-create", + ), + path( + "/project-attribute-delete/", + project_views.ProjectAttributeDeleteView.as_view(), + name="project-attribute-delete", + ), + path( + "/project-attribute-update/", + project_views.ProjectAttributeUpdateView.as_view(), + name="project-attribute-update", + ), ] diff --git a/coldfront/core/project/utils.py b/coldfront/core/project/utils.py index be5b611c52..137ba4ab26 100644 --- a/coldfront/core/project/utils.py +++ b/coldfront/core/project/utils.py @@ -1,20 +1,82 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + def add_project_status_choices(apps, schema_editor): - ProjectStatusChoice = apps.get_model('project', 'ProjectStatusChoice') + ProjectStatusChoice = apps.get_model("project", "ProjectStatusChoice") - for choice in ['New', 'Active', 'Archived', ]: + for choice in [ + "New", + "Active", + "Archived", + ]: ProjectStatusChoice.objects.get_or_create(name=choice) def add_project_user_role_choices(apps, schema_editor): - ProjectUserRoleChoice = apps.get_model('project', 'ProjectUserRoleChoice') + ProjectUserRoleChoice = apps.get_model("project", "ProjectUserRoleChoice") - for choice in ['User', 'Manager', ]: + for choice in [ + "User", + "Manager", + ]: ProjectUserRoleChoice.objects.get_or_create(name=choice) def add_project_user_status_choices(apps, schema_editor): - ProjectUserStatusChoice = apps.get_model('project', 'ProjectUserStatusChoice') + ProjectUserStatusChoice = apps.get_model("project", "ProjectUserStatusChoice") - for choice in ['Active', 'Pending Remove', 'Denied', 'Removed', ]: + for choice in [ + "Active", + "Pending Remove", + "Denied", + "Removed", + ]: ProjectUserStatusChoice.objects.get_or_create(name=choice) + + +def generate_project_code(project_code: str, project_pk: int, padding: int = 0) -> str: + """ + Generate a formatted project code by combining an uppercased user-defined project code, + project primary key and requested padding value (default = 0). + + :param project_code: The base project code, set through the PROJECT_CODE configuration variable. + :param project_pk: The primary key of the project. + :param padding: The number of digits to pad the primary key with, set through the PROJECT_CODE_PADDING configuration variable. + :return: A formatted project code string. + """ + + return f"{project_code.upper()}{str(project_pk).zfill(padding)}" + + +def determine_automated_institution_choice(project, institution_map: dict): + """ + Determine automated institution choice for a project. Taking PI email of current project + and comparing to domain key from institution_map. Will first try to match a domain exactly + as provided in institution_map, if a direct match cannot be found an indirect match will be + attempted by looking for the first occurrence of an institution domain that occurs as a substring + in the PI's email address. This does not save changes to the database. The project object in + memory will have the institution field modified. + :param project: Project to add automated institution choice to. + :param institution_map: Dictionary of institution keys, values. + """ + email: str = project.pi.email + + try: + _, pi_email_domain = email.split("@") + except ValueError: + pi_email_domain = None + + direct_institution_match = institution_map.get(pi_email_domain) + + if direct_institution_match: + project.institution = direct_institution_match + return direct_institution_match + else: + for institution_email_domain, indirect_institution_match in institution_map.items(): + if institution_email_domain in pi_email_domain: + project.institution = indirect_institution_match + return indirect_institution_match + + return project.institution diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 39d144c5e0..8c1645e4b1 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -1,58 +1,63 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime -from pipes import Template -import pprint -import django -import logging -from django import forms +import logging +from django import forms from django.conf import settings from django.contrib import messages -from django import forms +from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.contrib.auth.decorators import user_passes_test, login_required from django.contrib.auth.models import User -from coldfront.core.utils.common import import_from_settings from django.contrib.messages.views import SuccessMessageMixin from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q -from django.forms import formset_factory, modelformset_factory -from django.http import (HttpResponse, HttpResponseForbidden, - HttpResponseRedirect) +from django.forms import formset_factory +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render -from django.template.loader import render_to_string from django.urls import reverse -from coldfront.core.allocation.utils import generate_guauge_data_from_usage from django.views import View from django.views.generic import CreateView, DetailView, ListView, UpdateView from django.views.generic.base import TemplateView from django.views.generic.edit import FormView -from coldfront.core.allocation.models import (Allocation, - AllocationStatusChoice, - AllocationUser, - AllocationUserStatusChoice) -from coldfront.core.allocation.signals import (allocation_activate_user, - allocation_remove_user) +from coldfront.core.allocation.models import ( + Allocation, + AllocationStatusChoice, +) from coldfront.core.grant.models import Grant -from coldfront.core.project.forms import (ProjectAddUserForm, - ProjectAddUsersToAllocationForm, - ProjectAttributeAddForm, - ProjectAttributeDeleteForm, - ProjectRemoveUserForm, - ProjectReviewEmailForm, - ProjectReviewForm, - ProjectSearchForm, - ProjectUserUpdateForm, - ProjectAttributeUpdateForm) -from coldfront.core.project.models import (Project, - ProjectAttribute, - ProjectReview, - ProjectReviewStatusChoice, - ProjectStatusChoice, - ProjectUser, - ProjectUserRoleChoice, - ProjectUserStatusChoice, - ProjectUserMessage) +from coldfront.core.project.forms import ( + ProjectAddUserForm, + ProjectAddUsersToAllocationForm, + ProjectAttributeAddForm, + ProjectAttributeDeleteForm, + ProjectAttributeUpdateForm, + ProjectCreationForm, + ProjectRemoveUserForm, + ProjectReviewEmailForm, + ProjectReviewForm, + ProjectSearchForm, + ProjectUserUpdateForm, +) +from coldfront.core.project.models import ( + Project, + ProjectAttribute, + ProjectReview, + ProjectReviewStatusChoice, + ProjectStatusChoice, + ProjectUser, + ProjectUserMessage, + ProjectUserRoleChoice, + ProjectUserStatusChoice, +) +from coldfront.core.project.signals import ( + project_archive, + project_new, + project_update, +) +from coldfront.core.project.utils import determine_automated_institution_choice, generate_project_code from coldfront.core.publication.models import Publication from coldfront.core.research_output.models import ResearchOutput from coldfront.core.user.forms import UserSearchForm @@ -60,125 +65,156 @@ from coldfront.core.utils.common import get_domain_url, import_from_settings from coldfront.core.utils.mail import send_email, send_email_template -EMAIL_ENABLED = import_from_settings('EMAIL_ENABLED', False) -ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings( - 'ALLOCATION_ENABLE_ALLOCATION_RENEWAL', True) -ALLOCATION_DEFAULT_ALLOCATION_LENGTH = import_from_settings( - 'ALLOCATION_DEFAULT_ALLOCATION_LENGTH', 365) +ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings("ALLOCATION_ENABLE_ALLOCATION_RENEWAL", True) +ALLOCATION_DEFAULT_ALLOCATION_LENGTH = import_from_settings("ALLOCATION_DEFAULT_ALLOCATION_LENGTH", 365) -if EMAIL_ENABLED: - EMAIL_DIRECTOR_EMAIL_ADDRESS = import_from_settings( - 'EMAIL_DIRECTOR_EMAIL_ADDRESS') - EMAIL_SENDER = import_from_settings('EMAIL_SENDER') +EMAIL_DIRECTOR_EMAIL_ADDRESS = import_from_settings("EMAIL_DIRECTOR_EMAIL_ADDRESS") + +PROJECT_CODE = import_from_settings("PROJECT_CODE", False) +PROJECT_CODE_PADDING = import_from_settings("PROJECT_CODE_PADDING", False) +PROJECT_UPDATE_FIELDS = import_from_settings( + "PROJECT_UPDATE_FIELDS", + [ + "title", + "description", + "field_of_science", + ], +) logger = logging.getLogger(__name__) +PROJECT_INSTITUTION_EMAIL_MAP = import_from_settings("PROJECT_INSTITUTION_EMAIL_MAP", False) + class ProjectDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView): model = Project - template_name = 'project/project_detail.html' - context_object_name = 'project' + template_name = "project/project_detail.html" + context_object_name = "project" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('project.can_view_all_projects'): + if self.request.user.has_perm("project.can_view_all_projects"): return True project_obj = self.get_object() - if project_obj.projectuser_set.filter(user=self.request.user, status__name='Active').exists(): + if project_obj.projectuser_set.filter(user=self.request.user, status__name="Active").exists(): return True - messages.error( - self.request, 'You do not have permission to view the previous page.') + messages.error(self.request, "You do not have permission to view the previous page.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # Can the user update the project? + project_obj = self.get_object(Project.objects.select_related("status")) + project_user = project_obj.projectuser_set.select_related("role").filter(user=self.request.user) if self.request.user.is_superuser: - context['is_allowed_to_update_project'] = True - elif self.object.projectuser_set.filter(user=self.request.user).exists(): - project_user = self.object.projectuser_set.get( - user=self.request.user) - if project_user.role.name == 'Manager': - context['is_allowed_to_update_project'] = True + context["is_allowed_to_update_project"] = True + elif project_user: + project_user = project_user.first() + if project_user.role.name == "Manager": + context["is_allowed_to_update_project"] = True else: - context['is_allowed_to_update_project'] = False + context["is_allowed_to_update_project"] = False else: - context['is_allowed_to_update_project'] = False - - pk = self.kwargs.get('pk') - project_obj = get_object_or_404(Project, pk=pk) + context["is_allowed_to_update_project"] = False + attributes_query = project_obj.projectattribute_set.select_related("proj_attr_type", "projectattributeusage") if self.request.user.is_superuser: - attributes_with_usage = [attribute for attribute in project_obj.projectattribute_set.all( - ).order_by('proj_attr_type__name') if hasattr(attribute, 'projectattributeusage')] + attributes_with_usage = [ + attribute + for attribute in attributes_query.all().order_by("proj_attr_type__name") + if hasattr(attribute, "projectattributeusage") + ] - attributes = [attribute for attribute in project_obj.projectattribute_set.all( - ).order_by('proj_attr_type__name')] + attributes = [attribute for attribute in attributes_query.all().order_by("proj_attr_type__name")] else: - attributes_with_usage = [attribute for attribute in project_obj.projectattribute_set.filter( - proj_attr_type__is_private=False) if hasattr(attribute, 'projectattributeusage')] + attributes_with_usage = [ + attribute + for attribute in attributes_query.filter(proj_attr_type__is_private=False) + if hasattr(attribute, "projectattributeusage") + ] - attributes = [attribute for attribute in project_obj.projectattribute_set.filter( - proj_attr_type__is_private=False)] + attributes = [attribute for attribute in attributes_query.filter(proj_attr_type__is_private=False)] - guage_data = [] invalid_attributes = [] for attribute in attributes_with_usage: try: - guage_data.append(generate_guauge_data_from_usage(attribute.proj_attr_type.name, - float(attribute.value), float(attribute.projectattributeusage.value))) + float(attribute.value) + float(attribute.projectattributeusage.value) except ValueError: - logger.error("Allocation attribute '%s' is not an int but has a usage", - attribute.allocation_attribute_type.name) + logger.error("Project attribute '%s' is not an int but has a usage", attribute.proj_attr_type.name) invalid_attributes.append(attribute) for a in invalid_attributes: attributes_with_usage.remove(a) # Only show 'Active Users' - project_users = self.object.projectuser_set.filter( - status__name='Active').order_by('user__username') + project_users = ( + project_obj.projectuser_set.select_related("user", "role", "status") + .filter(status__name="Active") + .order_by("user__username") + ) - context['mailto'] = 'mailto:' + \ - ','.join([user.user.email for user in project_users]) + context["mailto"] = "mailto:" + ",".join([user.user.email for user in project_users]) - if self.request.user.is_superuser or self.request.user.has_perm('allocation.can_view_all_allocations'): - allocations = Allocation.objects.prefetch_related( - 'resources').filter(project=self.object).order_by('-end_date') + allocations = Allocation.objects.select_related("status").prefetch_related("resources") + if self.request.user.is_superuser or self.request.user.has_perm("allocation.can_view_all_allocations"): + allocations = allocations.filter(project=project_obj).order_by("-end_date") else: - if self.object.status.name in ['Active', 'New', ]: - allocations = Allocation.objects.filter( - Q(project=self.object) & - Q(project__projectuser__user=self.request.user) & - Q(project__projectuser__status__name__in=['Active', ]) & - Q(allocationuser__user=self.request.user) & - Q(allocationuser__status__name__in=['Active', ]) - ).distinct().order_by('-end_date') + if project_obj.status.name in [ + "Active", + "New", + ]: + allocations = ( + allocations.filter( + Q(project=project_obj) + & Q(project__projectuser__user=self.request.user) + & Q( + project__projectuser__status__name__in=[ + "Active", + ] + ) + & ( + Q(allocationuser__user=self.request.user) + & Q(allocationuser__status__name__in=["Active", "PendingEULA"]) + ) + | Q(project__projectuser__role__name="Manager") + ) + .distinct() + .order_by("-end_date") + ) else: - allocations = Allocation.objects.prefetch_related( - 'resources').filter(project=self.object) - - context['publications'] = Publication.objects.filter( - project=self.object, status='Active').order_by('-year') - context['research_outputs'] = ResearchOutput.objects.filter( - project=self.object).order_by('-created') - context['grants'] = Grant.objects.filter( - project=self.object, status__name__in=['Active', 'Pending', 'Archived']) - context['allocations'] = allocations - context['attributes'] = attributes - context['guage_data'] = guage_data - context['attributes_with_usage'] = attributes_with_usage - context['project_users'] = project_users - context['ALLOCATION_ENABLE_ALLOCATION_RENEWAL'] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL + allocations = allocations.filter(project=project_obj) + + user_status = [] + for allocation in allocations: + allocation_user = allocation.allocationuser_set.select_related("status").filter(user=self.request.user) + if allocation_user: + user_status.append(allocation_user.first().status.name) + + note_set = project_obj.projectusermessage_set + notes = note_set.all() if self.request.user.is_superuser else note_set.filter(is_private=False) + context["notes"] = notes + context["publications"] = ( + Publication.objects.select_related("source").filter(project=project_obj, status="Active").order_by("-year") + ) + context["research_outputs"] = ResearchOutput.objects.filter(project=project_obj).order_by("-created") + context["grants"] = Grant.objects.select_related("status").filter( + project=project_obj, status__name__in=["Active", "Pending", "Archived"] + ) + context["allocations"] = allocations + context["user_allocation_status"] = user_status + context["attributes"] = attributes + context["attributes_with_usage"] = attributes_with_usage + context["project_users"] = project_users try: - context['ondemand_url'] = settings.ONDEMAND_URL + context["ondemand_url"] = settings.ONDEMAND_URL except AttributeError: pass @@ -186,106 +222,150 @@ def get_context_data(self, **kwargs): class ProjectListView(LoginRequiredMixin, ListView): - model = Project - template_name = 'project/project_list.html' - prefetch_related = ['pi', 'status', 'field_of_science', ] - context_object_name = 'project_list' + template_name = "project/project_list.html" + prefetch_related = [ + "pi", + "status", + "field_of_science", + ] + context_object_name = "project_list" paginate_by = 25 def get_queryset(self): - - order_by = self.request.GET.get('order_by', 'id') - direction = self.request.GET.get('direction', 'asc') + order_by = self.request.GET.get("order_by", "id") + direction = self.request.GET.get("direction", "asc") if order_by != "name": - if direction == 'asc': - direction = '' - if direction == 'des': - direction = '-' + if direction == "asc": + direction = "" + if direction == "des": + direction = "-" order_by = direction + order_by project_search_form = ProjectSearchForm(self.request.GET) + projects = Project.objects.prefetch_related("pi", "field_of_science", "status") + if project_search_form.is_valid(): data = project_search_form.cleaned_data - if data.get('show_all_projects') and (self.request.user.is_superuser or self.request.user.has_perm('project.can_view_all_projects')): - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - status__name__in=['New', 'Active', ]).order_by(order_by) + if data.get("show_all_projects") and ( + self.request.user.is_superuser or self.request.user.has_perm("project.can_view_all_projects") + ): + projects = ( + Project.objects.select_related( + "pi", + "field_of_science", + "status", + ) + .filter( + status__name__in=[ + "New", + "Active", + ] + ) + .order_by(order_by) + ) else: - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - Q(status__name__in=['New', 'Active', ]) & - Q(projectuser__user=self.request.user) & - Q(projectuser__status__name='Active') - ).order_by(order_by) + projects = ( + Project.objects.select_related( + "pi", + "field_of_science", + "status", + ) + .filter( + Q( + status__name__in=[ + "New", + "Active", + ] + ) + & Q(projectuser__user=self.request.user) + & Q(projectuser__status__name="Active") + ) + .order_by(order_by) + ) # Last Name - if data.get('last_name'): - projects = projects.filter( - pi__last_name__icontains=data.get('last_name')) + if data.get("title"): + projects = projects.filter(title__icontains=data.get("title")) + + # Last Name + if data.get("last_name"): + projects = projects.filter(pi__last_name__icontains=data.get("last_name")) # Username - if data.get('username'): + if data.get("username"): projects = projects.filter( - Q(pi__username__icontains=data.get('username')) | - Q(projectuser__user__username__icontains=data.get('username')) & - Q(projectuser__status__name='Active') + Q(pi__username__icontains=data.get("username")) + | Q(projectuser__user__username__icontains=data.get("username")) + & Q(projectuser__status__name="Active") ) # Field of Science - if data.get('field_of_science'): - projects = projects.filter( - field_of_science__description__icontains=data.get('field_of_science')) + if data.get("field_of_science"): + projects = projects.filter(field_of_science__description__icontains=data.get("field_of_science")) else: - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - Q(status__name__in=['New', 'Active', ]) & - Q(projectuser__user=self.request.user) & - Q(projectuser__status__name='Active') - ).order_by(order_by) + projects = ( + Project.objects.select_related( + "pi", + "field_of_science", + "status", + ) + .filter( + Q( + status__name__in=[ + "New", + "Active", + ] + ) + & Q(projectuser__user=self.request.user) + & Q(projectuser__status__name="Active") + ) + .order_by(order_by) + ) - return projects.distinct() + return projects.order_by(order_by).distinct() def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) projects_count = self.get_queryset().count() - context['projects_count'] = projects_count + context["projects_count"] = projects_count project_search_form = ProjectSearchForm(self.request.GET) if project_search_form.is_valid(): - context['project_search_form'] = project_search_form + context["project_search_form"] = project_search_form data = project_search_form.cleaned_data - filter_parameters = '' + filter_parameters = "" for key, value in data.items(): if value: if isinstance(value, list): for ele in value: - filter_parameters += '{}={}&'.format(key, ele) + filter_parameters += "{}={}&".format(key, ele) else: - filter_parameters += '{}={}&'.format(key, value) - context['project_search_form'] = project_search_form + filter_parameters += "{}={}&".format(key, value) + context["project_search_form"] = project_search_form else: filter_parameters = None - context['project_search_form'] = ProjectSearchForm() + context["project_search_form"] = ProjectSearchForm() - order_by = self.request.GET.get('order_by') + order_by = self.request.GET.get("order_by") if order_by: - direction = self.request.GET.get('direction') - filter_parameters_with_order_by = filter_parameters + \ - 'order_by=%s&direction=%s&' % (order_by, direction) + direction = self.request.GET.get("direction") + filter_parameters_with_order_by = filter_parameters + "order_by=%s&direction=%s&" % (order_by, direction) else: filter_parameters_with_order_by = filter_parameters if filter_parameters: - context['expand_accordion'] = 'show' + context["expand_accordion"] = "show" - context['filter_parameters'] = filter_parameters - context['filter_parameters_with_order_by'] = filter_parameters_with_order_by + context["filter_parameters"] = filter_parameters + context["filter_parameters_with_order_by"] = filter_parameters_with_order_by - project_list = context.get('project_list') + project_list = context.get("project_list") paginator = Paginator(project_list, self.paginate_by) - page = self.request.GET.get('page') + page = self.request.GET.get("page") try: project_list = paginator.page(page) @@ -298,103 +378,136 @@ def get_context_data(self, **kwargs): class ProjectArchivedListView(LoginRequiredMixin, ListView): - model = Project - template_name = 'project/project_archived_list.html' - prefetch_related = ['pi', 'status', 'field_of_science', ] - context_object_name = 'project_list' + template_name = "project/project_archived_list.html" + prefetch_related = [ + "pi", + "status", + "field_of_science", + ] + context_object_name = "project_list" paginate_by = 10 def get_queryset(self): - - order_by = self.request.GET.get('order_by', 'id') - direction = self.request.GET.get('direction', '') + order_by = self.request.GET.get("order_by", "id") + direction = self.request.GET.get("direction", "") if order_by != "name": - if direction == 'des': - direction = '-' + if direction == "des": + direction = "-" order_by = direction + order_by project_search_form = ProjectSearchForm(self.request.GET) if project_search_form.is_valid(): data = project_search_form.cleaned_data - if data.get('show_all_projects') and (self.request.user.is_superuser or self.request.user.has_perm('project.can_view_all_projects')): - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - status__name__in=['Archived', ]).order_by(order_by) + if data.get("show_all_projects") and ( + self.request.user.is_superuser or self.request.user.has_perm("project.can_view_all_projects") + ): + projects = ( + Project.objects.prefetch_related( + "pi", + "field_of_science", + "status", + ) + .filter( + status__name__in=[ + "Archived", + ] + ) + .order_by(order_by) + ) else: - - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - Q(status__name__in=['Archived', ]) & - Q(projectuser__user=self.request.user) & - Q(projectuser__status__name='Active') - ).order_by(order_by) + projects = ( + Project.objects.prefetch_related( + "pi", + "field_of_science", + "status", + ) + .filter( + Q( + status__name__in=[ + "Archived", + ] + ) + & Q(projectuser__user=self.request.user) + & Q(projectuser__status__name="Active") + ) + .order_by(order_by) + ) # Last Name - if data.get('last_name'): - projects = projects.filter( - pi__last_name__icontains=data.get('last_name')) + if data.get("last_name"): + projects = projects.filter(pi__last_name__icontains=data.get("last_name")) # Username - if data.get('username'): - projects = projects.filter( - pi__username__icontains=data.get('username')) + if data.get("username"): + projects = projects.filter(pi__username__icontains=data.get("username")) # Field of Science - if data.get('field_of_science'): - projects = projects.filter( - field_of_science__description__icontains=data.get('field_of_science')) + if data.get("field_of_science"): + projects = projects.filter(field_of_science__description__icontains=data.get("field_of_science")) else: - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - Q(status__name__in=['Archived', ]) & - Q(projectuser__user=self.request.user) & - Q(projectuser__status__name='Active') - ).order_by(order_by) + projects = ( + Project.objects.prefetch_related( + "pi", + "field_of_science", + "status", + ) + .filter( + Q( + status__name__in=[ + "Archived", + ] + ) + & Q(projectuser__user=self.request.user) + & Q(projectuser__status__name="Active") + ) + .order_by(order_by) + ) return projects def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) projects_count = self.get_queryset().count() - context['projects_count'] = projects_count - context['expand'] = False + context["projects_count"] = projects_count + context["expand"] = False project_search_form = ProjectSearchForm(self.request.GET) if project_search_form.is_valid(): - context['project_search_form'] = project_search_form + context["project_search_form"] = project_search_form data = project_search_form.cleaned_data - filter_parameters = '' + filter_parameters = "" for key, value in data.items(): if value: if isinstance(value, list): for ele in value: - filter_parameters += '{}={}&'.format(key, ele) + filter_parameters += "{}={}&".format(key, ele) else: - filter_parameters += '{}={}&'.format(key, value) - context['project_search_form'] = project_search_form + filter_parameters += "{}={}&".format(key, value) + context["project_search_form"] = project_search_form else: filter_parameters = None - context['project_search_form'] = ProjectSearchForm() + context["project_search_form"] = ProjectSearchForm() - order_by = self.request.GET.get('order_by') + order_by = self.request.GET.get("order_by") if order_by: - direction = self.request.GET.get('direction') - filter_parameters_with_order_by = filter_parameters + \ - 'order_by=%s&direction=%s&' % (order_by, direction) + direction = self.request.GET.get("direction") + filter_parameters_with_order_by = filter_parameters + "order_by=%s&direction=%s&" % (order_by, direction) else: filter_parameters_with_order_by = filter_parameters if filter_parameters: - context['expand_accordion'] = 'show' + context["expand_accordion"] = "show" - context['filter_parameters'] = filter_parameters - context['filter_parameters_with_order_by'] = filter_parameters_with_order_by + context["filter_parameters"] = filter_parameters + context["filter_parameters_with_order_by"] = filter_parameters_with_order_by - project_list = context.get('project_list') + project_list = context.get("project_list") paginator = Paginator(project_list, self.paginate_by) - page = self.request.GET.get('page') + page = self.request.GET.get("page") try: project_list = paginator.page(page) @@ -407,54 +520,68 @@ def get_context_data(self, **kwargs): class ProjectArchiveProjectView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_archive.html' + template_name = "project/project_archive.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project = get_object_or_404(Project, pk=pk) - context['project'] = project + context["project"] = project return context def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project = get_object_or_404(Project, pk=pk) - project_status_archive = ProjectStatusChoice.objects.get( - name='Archived') - allocation_status_expired = AllocationStatusChoice.objects.get( - name='Expired') + project_status_archive = ProjectStatusChoice.objects.get(name="Archived") + allocation_status_expired = AllocationStatusChoice.objects.get(name="Expired") end_date = datetime.datetime.now() project.status = project_status_archive project.save() - for allocation in project.allocation_set.filter(status__name='Active'): + + # project signals + project_archive.send(sender=self.__class__, project_obj=project) + + # send email to project members + email_recipients = project.get_user_emails() + + send_email_template( + "Project has been archived", + "email/project_archived.txt", + {"project": project}, + email_recipients, + ) + + for allocation in project.allocation_set.filter(status__name="Active"): allocation.status = allocation_status_expired allocation.end_date = end_date allocation.save() - return redirect(reverse('project-detail', kwargs={'pk': project.pk})) + return redirect(project) class ProjectCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = Project - template_name_suffix = '_create_form' - fields = ['title', 'description', 'field_of_science', ] + template_name_suffix = "_create_form" + form_class = ProjectCreationForm def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True @@ -464,31 +591,42 @@ def test_func(self): def form_valid(self, form): project_obj = form.save(commit=False) form.instance.pi = self.request.user - form.instance.status = ProjectStatusChoice.objects.get(name='New') + form.instance.status = ProjectStatusChoice.objects.get(name="New") project_obj.save() self.object = project_obj - project_user_obj = ProjectUser.objects.create( + ProjectUser.objects.create( user=self.request.user, project=project_obj, - role=ProjectUserRoleChoice.objects.get(name='Manager'), - status=ProjectUserStatusChoice.objects.get(name='Active') + role=ProjectUserRoleChoice.objects.get(name="Manager"), + status=ProjectUserStatusChoice.objects.get(name="Active"), ) - return super().form_valid(form) + if PROJECT_CODE: + """ + Set the ProjectCode object, if PROJECT_CODE is defined. + If PROJECT_CODE_PADDING is defined, the set amount of padding will be added to PROJECT_CODE. + """ + project_obj.project_code = generate_project_code(PROJECT_CODE, project_obj.pk, PROJECT_CODE_PADDING or 0) + project_obj.save(update_fields=["project_code"]) - def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.pk}) + if PROJECT_INSTITUTION_EMAIL_MAP: + determine_automated_institution_choice(project_obj, PROJECT_INSTITUTION_EMAIL_MAP) + + # project signals + project_new.send(sender=self.__class__, project_obj=project_obj) + + return super().form_valid(form) class ProjectUpdateView(SuccessMessageMixin, LoginRequiredMixin, UserPassesTestMixin, UpdateView): model = Project - template_name_suffix = '_update_form' - fields = ['title', 'description', 'field_of_science', ] - success_message = 'Project updated.' + template_name_suffix = "_update_form" + fields = PROJECT_UPDATE_FIELDS + success_message = "Project updated." def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True @@ -497,435 +635,485 @@ def test_func(self): if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True 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', ]: - messages.error(request, 'You cannot update an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + + if PROJECT_CODE and project_obj.project_code == "": + """ + Updates project code if no value was set, providing the feature is activated. + """ + project_obj.project_code = generate_project_code(PROJECT_CODE, project_obj.pk, PROJECT_CODE_PADDING or 0) + project_obj.save(update_fields=["project_code"]) + + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot update an archived project.") + return redirect(project_obj.pk) else: return super().dispatch(request, *args, **kwargs) def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.pk}) + # project signals + project_update.send(sender=self.__class__, project_obj=self.object) + return super().get_success_url() class ProjectAddUsersSearchView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_add_users.html' + template_name = "project/project_add_users.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True 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', ]: - messages.error( - request, 'You cannot add users to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project.objects.select_related("status"), pk=self.kwargs.get("pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add users to an archived project.") + return redirect(project_obj.pk) else: return super().dispatch(request, *args, **kwargs) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['user_search_form'] = UserSearchForm() - context['project'] = Project.objects.get(pk=self.kwargs.get('pk')) + context["user_search_form"] = UserSearchForm() + context["project"] = Project.objects.get(pk=self.kwargs.get("pk")) return context class ProjectAddUsersSearchResultsView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/add_user_search_results.html' + template_name = "project/add_user_search_results.html" raise_exception = True def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True 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', ]: - messages.error( - request, 'You cannot add users to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project.objects.select_related("status"), pk=self.kwargs.get("pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add users to an archived project.") + return redirect(project_obj.pk) else: return super().dispatch(request, *args, **kwargs) + def get_initial_data(self, project_obj): + allocation_objs = project_obj.allocation_set.select_related("status").filter( + resources__is_allocatable=True, + is_locked=False, + status__name__in=["Active", "New", "Renewal Requested", "Payment Pending", "Payment Requested", "Paid"], + ) + initial_data = [] + for allocation_obj in allocation_objs: + resource = allocation_obj.get_parent_resource + initial_data.append( + { + "pk": allocation_obj.pk, + "resource": resource.name, + "details": allocation_obj.get_information, + "resource_type": resource.resource_type.name, + "status": allocation_obj.status.name, + } + ) + return initial_data + def post(self, request, *args, **kwargs): - user_search_string = request.POST.get('q') - search_by = request.POST.get('search_by') - pk = self.kwargs.get('pk') + user_search_string = request.POST.get("q") + search_by = request.POST.get("search_by") + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) - users_to_exclude = [ele.user.username for ele in project_obj.projectuser_set.filter( - status__name='Active')] + users_to_exclude = [ + ele.user.username + for ele in project_obj.projectuser_set.select_related("user").filter(status__name="Active") + ] - cobmined_user_search_obj = CombinedUserSearch( - user_search_string, search_by, users_to_exclude) + cobmined_user_search_obj = CombinedUserSearch(user_search_string, search_by, users_to_exclude) context = cobmined_user_search_obj.search() - matches = context.get('matches') + matches = context.get("matches") + user_role = ProjectUserRoleChoice.objects.get(name="User") for match in matches: - match.update( - {'role': ProjectUserRoleChoice.objects.get(name='User')}) + match.update({"role": user_role}) if matches: formset = formset_factory(ProjectAddUserForm, max_num=len(matches)) - formset = formset(initial=matches, prefix='userform') - context['formset'] = formset - context['user_search_string'] = user_search_string - context['search_by'] = search_by + formset = formset(initial=matches, prefix="userform") + context["formset"] = formset + context["user_search_string"] = user_search_string + context["search_by"] = search_by if len(user_search_string.split()) > 1: users_already_in_project = [] for ele in user_search_string.split(): if ele in users_to_exclude: users_already_in_project.append(ele) - context['users_already_in_project'] = users_already_in_project + context["users_already_in_project"] = users_already_in_project # The following block of code is used to hide/show the allocation div in the form. - if project_obj.allocation_set.filter(status__name__in=['Active', 'New', 'Renewal Requested']).exists(): - div_allocation_class = 'placeholder_div_class' + if project_obj.allocation_set.filter(status__name__in=["Active", "New", "Renewal Requested"]).exists(): + div_allocation_class = "placeholder_div_class" else: - div_allocation_class = 'd-none' - context['div_allocation_class'] = div_allocation_class + div_allocation_class = "d-none" + context["div_allocation_class"] = div_allocation_class ### - allocation_form = ProjectAddUsersToAllocationForm( - request.user, project_obj.pk, prefix='allocationform') - context['pk'] = pk - context['allocation_form'] = allocation_form + initial_data = self.get_initial_data(project_obj) + allocation_formset = formset_factory(ProjectAddUsersToAllocationForm, max_num=len(initial_data)) + allocation_formset = allocation_formset(initial=initial_data, prefix="allocationform") + + context["pk"] = pk + context["allocation_formset"] = allocation_formset return render(request, self.template_name, context) class ProjectAddUsersView(LoginRequiredMixin, UserPassesTestMixin, View): - def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True 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', ]: - messages.error( - request, 'You cannot add users to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add users to an archived project.") + return redirect(project_obj.pk) else: return super().dispatch(request, *args, **kwargs) + def get_initial_data(self, project_obj): + allocation_objs = project_obj.allocation_set.select_related("status").filter( + resources__is_allocatable=True, + is_locked=False, + status__name__in=["Active", "New", "Renewal Requested", "Payment Pending", "Payment Requested", "Paid"], + ) + initial_data = [] + for allocation_obj in allocation_objs: + resource = allocation_obj.get_parent_resource + initial_data.append( + { + "pk": allocation_obj.pk, + "resource": resource.name, + "details": allocation_obj.get_information, + "resource_type": resource.resource_type.name, + "status": allocation_obj.status.name, + } + ) + return initial_data + def post(self, request, *args, **kwargs): - user_search_string = request.POST.get('q') - search_by = request.POST.get('search_by') - pk = self.kwargs.get('pk') + user_search_string = request.POST.get("q") + search_by = request.POST.get("search_by") + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) - users_to_exclude = [ele.user.username for ele in project_obj.projectuser_set.filter( - status__name='Active')] + users_to_exclude = [ele.user.username for ele in project_obj.projectuser_set.filter(status__name="Active")] - cobmined_user_search_obj = CombinedUserSearch( - user_search_string, search_by, users_to_exclude) + cobmined_user_search_obj = CombinedUserSearch(user_search_string, search_by, users_to_exclude) context = cobmined_user_search_obj.search() - matches = context.get('matches') + matches = context.get("matches") + project_user_role = ProjectUserRoleChoice.objects.get(name="User") for match in matches: - match.update( - {'role': ProjectUserRoleChoice.objects.get(name='User')}) + match.update({"role": project_user_role}) formset = formset_factory(ProjectAddUserForm, max_num=len(matches)) - formset = formset(request.POST, initial=matches, prefix='userform') + formset = formset(request.POST, initial=matches, prefix="userform") - allocation_form = ProjectAddUsersToAllocationForm( - request.user, project_obj.pk, request.POST, prefix='allocationform') + initial_data = self.get_initial_data(project_obj) + allocation_formset = formset_factory( + ProjectAddUsersToAllocationForm, + max_num=len(initial_data), + ) + allocation_formset = allocation_formset( + request.POST, + initial=initial_data, + prefix="allocationform", + ) added_users_count = 0 - if formset.is_valid() and allocation_form.is_valid(): - project_user_active_status_choice = ProjectUserStatusChoice.objects.get( - name='Active') - allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get( - name='Active') - allocation_form_data = allocation_form.cleaned_data['allocation'] - if '__select_all__' in allocation_form_data: - allocation_form_data.remove('__select_all__') + if formset.is_valid() and allocation_formset.is_valid(): + allocations_selected_objs = Allocation.objects.filter( + pk__in=[ + allocation_form.cleaned_data.get("pk") + for allocation_form in allocation_formset + if allocation_form.cleaned_data.get("selected") + ] + ) for form in formset: user_form_data = form.cleaned_data - if user_form_data['selected']: + if user_form_data["selected"]: added_users_count += 1 # Will create local copy of user if not already present in local database - user_obj, _ = User.objects.get_or_create( - username=user_form_data.get('username')) - user_obj.first_name = user_form_data.get('first_name') - user_obj.last_name = user_form_data.get('last_name') - user_obj.email = user_form_data.get('email') - user_obj.save() - - role_choice = user_form_data.get('role') - # Is the user already in the project? - if project_obj.projectuser_set.filter(user=user_obj).exists(): - project_user_obj = project_obj.projectuser_set.get( - user=user_obj) - project_user_obj.role = role_choice - project_user_obj.status = project_user_active_status_choice - project_user_obj.save() - else: - project_user_obj = ProjectUser.objects.create( - user=user_obj, project=project_obj, role=role_choice, status=project_user_active_status_choice) - - for allocation in Allocation.objects.filter(pk__in=allocation_form_data): - if allocation.allocationuser_set.filter(user=user_obj).exists(): - allocation_user_obj = allocation.allocationuser_set.get( - user=user_obj) - allocation_user_obj.status = allocation_user_active_status_choice - allocation_user_obj.save() - else: - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation, - user=user_obj, - status=allocation_user_active_status_choice) - allocation_activate_user.send(sender=self.__class__, - allocation_user_pk=allocation_user_obj.pk) - - messages.success( - request, 'Added {} users to project.'.format(added_users_count)) + user_obj, created = User.objects.get_or_create(username=user_form_data.get("username")) + if created: + user_obj.first_name = user_form_data.get("first_name") + user_obj.last_name = user_form_data.get("last_name") + user_obj.email = user_form_data.get("email") + user_obj.save() + + role_choice = user_form_data.get("role") + project_obj.add_user(user_obj, role_choice, signal_sender=self.__class__) + + email_context = { + "user": user_obj, + "project": project_obj, + "allocations": [], + } + + for allocation in allocations_selected_objs: + allocation.add_user(user_obj, signal_sender=self.__class__) + if allocation.allocationuser_set.get(user=user_obj).status.name == "Active": + email_context["allocations"].append(allocation) + + send_email_template( + "You have been added to a project", + "email/user_added_to_project.txt", + email_context, + [user_obj.email], + ) + + messages.success(request, "Added {} users to project.".format(added_users_count)) else: if not formset.is_valid(): for error in formset.errors: messages.error(request, error) - - if not allocation_form.is_valid(): - for error in allocation_form.errors: + if not allocation_formset.is_valid(): + for error in allocation_formset.errors: messages.error(request, error) + return redirect(project_obj) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': pk})) + return redirect(project_obj) class ProjectRemoveUsersView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_remove_users.html' + template_name = "project/project_remove_users.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True 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', ]: - messages.error( - request, 'You cannot remove users from an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot remove users from an archived project.") + return redirect(project_obj) else: return super().dispatch(request, *args, **kwargs) def get_users_to_remove(self, project_obj): users_to_remove = [ - - {'username': ele.user.username, - 'first_name': ele.user.first_name, - 'last_name': ele.user.last_name, - 'email': ele.user.email, - 'role': ele.role} - - for ele in project_obj.projectuser_set.filter(status__name='Active').order_by('user__username') if ele.user != self.request.user and ele.user != project_obj.pi + { + "username": ele.user.username, + "first_name": ele.user.first_name, + "last_name": ele.user.last_name, + "email": ele.user.email, + "role": ele.role, + } + for ele in project_obj.projectuser_set.filter(status__name="Active").order_by("user__username") + if ele.user != self.request.user and ele.user != project_obj.pi ] return users_to_remove def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) users_to_remove = self.get_users_to_remove(project_obj) context = {} if users_to_remove: - formset = formset_factory( - ProjectRemoveUserForm, max_num=len(users_to_remove)) - formset = formset(initial=users_to_remove, prefix='userform') - context['formset'] = formset + formset = formset_factory(ProjectRemoveUserForm, max_num=len(users_to_remove)) + formset = formset(initial=users_to_remove, prefix="userform") + context["formset"] = formset - context['project'] = get_object_or_404(Project, pk=pk) + context["project"] = get_object_or_404(Project, pk=pk) return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) users_to_remove = self.get_users_to_remove(project_obj) - formset = formset_factory( - ProjectRemoveUserForm, max_num=len(users_to_remove)) - formset = formset( - request.POST, initial=users_to_remove, prefix='userform') + formset = formset_factory(ProjectRemoveUserForm, max_num=len(users_to_remove)) + formset = formset(request.POST, initial=users_to_remove, prefix="userform") remove_users_count = 0 if formset.is_valid(): - project_user_removed_status_choice = ProjectUserStatusChoice.objects.get( - name='Removed') - allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get( - name='Removed') for form in formset: user_form_data = form.cleaned_data - if user_form_data['selected']: - + if user_form_data["selected"]: remove_users_count += 1 - user_obj = User.objects.get( - username=user_form_data.get('username')) + user_obj = User.objects.get(username=user_form_data.get("username")) if project_obj.pi == user_obj: continue - project_user_obj = project_obj.projectuser_set.get( - user=user_obj) - project_user_obj.status = project_user_removed_status_choice - project_user_obj.save() - - # get allocation to remove users from - allocations_to_remove_user_from = project_obj.allocation_set.filter( - status__name__in=['Active', 'New', 'Renewal Requested']) - for allocation in allocations_to_remove_user_from: - for allocation_user_obj in allocation.allocationuser_set.filter(user=user_obj, status__name__in=['Active', ]): - allocation_user_obj.status = allocation_user_removed_status_choice - allocation_user_obj.save() - - allocation_remove_user.send(sender=self.__class__, - allocation_user_pk=allocation_user_obj.pk) + project_obj.remove_user(user_obj, signal_sender=self.__class__) if remove_users_count == 1: - messages.success( - request, 'Removed {} user from project.'.format(remove_users_count)) + messages.success(request, "Removed {} user from project.".format(remove_users_count)) else: - messages.success( - request, 'Removed {} users from project.'.format(remove_users_count)) + messages.success(request, "Removed {} users from project.".format(remove_users_count)) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': pk})) + return redirect(project_obj) class ProjectUserDetail(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_user_detail.html' + template_name = "project/project_user_detail.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def get(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - project_user_pk = self.kwargs.get('project_user_pk') - - if project_obj.projectuser_set.filter(pk=project_user_pk).exists(): - project_user_obj = project_obj.projectuser_set.get( - pk=project_user_pk) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + project_user_obj = get_object_or_404(ProjectUser, pk=self.kwargs.get("project_user_pk")) - project_user_update_form = ProjectUserUpdateForm( - initial={'role': project_user_obj.role, 'enable_notifications': project_user_obj.enable_notifications}) + project_user_update_form = ProjectUserUpdateForm( + initial={"role": project_user_obj.role, "enable_notifications": project_user_obj.enable_notifications} + ) - context = {} - context['project_obj'] = project_obj - context['project_user_update_form'] = project_user_update_form - context['project_user_obj'] = project_user_obj + context = {} + context["project_obj"] = project_obj + context["project_user_update_form"] = project_user_update_form + context["project_user_obj"] = project_user_obj - return render(request, self.template_name, context) + return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - project_user_pk = self.kwargs.get('project_user_pk') + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + project_user_pk = self.kwargs.get("project_user_pk") - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot update a user in an archived project.') - return HttpResponseRedirect(reverse('project-user-detail', kwargs={'pk': project_user_pk})) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot update a user in an archived project.") + return HttpResponseRedirect(reverse("project-user-detail", kwargs={"pk": project_user_pk})) if project_obj.projectuser_set.filter(id=project_user_pk).exists(): - project_user_obj = project_obj.projectuser_set.get( - pk=project_user_pk) + project_user_obj = project_obj.projectuser_set.get(pk=project_user_pk) if project_user_obj.user == project_user_obj.project.pi: - messages.error( - request, 'PI role and email notification option cannot be changed.') - return HttpResponseRedirect(reverse('project-user-detail', kwargs={'pk': project_user_pk})) + messages.error(request, "PI role and email notification option cannot be changed.") + return HttpResponseRedirect(reverse("project-user-detail", kwargs={"pk": project_user_pk})) - project_user_update_form = ProjectUserUpdateForm(request.POST, - initial={'role': project_user_obj.role.name, - 'enable_notifications': project_user_obj.enable_notifications} - ) + project_user_update_form = ProjectUserUpdateForm( + request.POST, + initial={ + "role": project_user_obj.role.name, + "enable_notifications": project_user_obj.enable_notifications, + }, + ) if project_user_update_form.is_valid(): form_data = project_user_update_form.cleaned_data - project_user_obj.role = ProjectUserRoleChoice.objects.get( - name=form_data.get('role')) - - if(project_user_obj.role.name=="Manager"): + project_user_obj.role = ProjectUserRoleChoice.objects.get(name=form_data.get("role")) + + if project_user_obj.role.name == "Manager": project_user_obj.enable_notifications = True else: - project_user_obj.enable_notifications = form_data.get( - 'enable_notifications') + project_user_obj.enable_notifications = form_data.get("enable_notifications") project_user_obj.save() - messages.success(request, 'User details updated.') - return HttpResponseRedirect(reverse('project-user-detail', kwargs={'pk': project_obj.pk, 'project_user_pk': project_user_obj.pk})) + messages.success(request, "User details updated.") + return HttpResponseRedirect( + reverse( + "project-user-detail", kwargs={"pk": project_obj.pk, "project_user_pk": project_user_obj.pk} + ) + ) @login_required def project_update_email_notification(request): - if request.method == "POST": data = request.POST - project_user_obj = get_object_or_404( - ProjectUser, pk=data.get('user_project_id')) - + project_user_obj = get_object_or_404(ProjectUser, pk=data.get("user_project_id")) project_obj = project_user_obj.project @@ -933,7 +1121,7 @@ def project_update_email_notification(request): if project_obj.pi == request.user: allowed = True - if project_obj.projectuser_set.filter(user=request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter(user=request.user, role__name="Manager", status__name="Active").exists(): allowed = True if project_user_obj.user == request.user: @@ -942,189 +1130,197 @@ def project_update_email_notification(request): if request.user.is_superuser: allowed = True - if allowed == False: - return HttpResponse('not allowed', status=403) + if allowed is False: + return HttpResponse("not allowed", status=403) else: - checked = data.get('checked') - if checked == 'true': + checked = data.get("checked") + if checked == "true": project_user_obj.enable_notifications = True project_user_obj.save() - return HttpResponse('checked', status=200) - elif checked == 'false': + return HttpResponse("checked", status=200) + elif checked == "false": project_user_obj.enable_notifications = False project_user_obj.save() - return HttpResponse('unchecked', status=200) + return HttpResponse("unchecked", status=200) else: - return HttpResponse('no checked', status=400) + return HttpResponse("no checked", status=400) else: - return HttpResponse('no POST', status=400) + return HttpResponse("no POST", status=400) class ProjectReviewView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_review.html' + template_name = "project/project_review.html" login_url = "/" # redirect URL if fail test_func def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error( - self.request, 'You do not have permissions to review this project.') + messages.error(self.request, "You do not have permissions to review this project.") def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if not project_obj.needs_review: - messages.error(request, 'You do not need to review this project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + messages.error(request, "You do not need to review this project.") + return redirect(project_obj) - if 'Auto-Import Project'.lower() in project_obj.title.lower(): + if "Auto-Import Project".lower() in project_obj.title.lower(): messages.error( - request, 'You must update the project title before reviewing your project. You cannot have "Auto-Import Project" in the title.') - return HttpResponseRedirect(reverse('project-update', kwargs={'pk': project_obj.pk})) + request, + 'You must update the project title before reviewing your project. You cannot have "Auto-Import Project" in the title.', + ) + return HttpResponseRedirect(reverse("project-update", kwargs={"pk": project_obj.pk})) - if 'We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!' in project_obj.description: - messages.error( - request, 'You must update the project description before reviewing your project.') - return HttpResponseRedirect(reverse('project-update', kwargs={'pk': project_obj.pk})) + if ( + "We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!" + in project_obj.description + ): + messages.error(request, "You must update the project description before reviewing your project.") + return HttpResponseRedirect(reverse("project-update", kwargs={"pk": project_obj.pk})) return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) project_review_form = ProjectReviewForm(project_obj.pk) context = {} - context['project'] = project_obj - context['project_review_form'] = project_review_form - context['project_users'] = ', '.join(['{} {}'.format(ele.user.first_name, ele.user.last_name) - for ele in project_obj.projectuser_set.filter(status__name='Active').order_by('user__last_name')]) + context["project"] = project_obj + context["project_review_form"] = project_review_form + context["project_users"] = ", ".join( + [ + "{} {}".format(ele.user.first_name, ele.user.last_name) + for ele in project_obj.projectuser_set.filter(status__name="Active").order_by("user__last_name") + ] + ) return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) project_review_form = ProjectReviewForm(project_obj.pk, request.POST) - project_review_status_choice = ProjectReviewStatusChoice.objects.get( - name='Pending') - - if project_review_form.is_valid(): - form_data = project_review_form.cleaned_data - project_review_obj = ProjectReview.objects.create( - project=project_obj, - reason_for_not_updating_project=form_data.get('reason'), - status=project_review_status_choice) - - project_obj.force_review = False - project_obj.save() - - domain_url = get_domain_url(self.request) - url = '{}{}'.format(domain_url, reverse('project-review-list')) - - if EMAIL_ENABLED: - send_email_template( - 'New project review has been submitted', - 'email/new_project_review.txt', - {'url': url}, - EMAIL_SENDER, - [EMAIL_DIRECTOR_EMAIL_ADDRESS, ] - ) + project_review_status_choice = ProjectReviewStatusChoice.objects.get(name="Pending") - messages.success(request, 'Project reviewed successfully.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) - else: - messages.error( - request, 'There was an error in processing your project review.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + if not project_review_form.is_valid(): + messages.error(request, "There was an error in processing your project review.") + return redirect(project_obj) + form_data = project_review_form.cleaned_data + project_review_obj = ProjectReview.objects.create( + project=project_obj, + reason_for_not_updating_project=form_data.get("reason"), + status=project_review_status_choice, + ) -class ProjectReviewListView(LoginRequiredMixin, UserPassesTestMixin, ListView): + project_obj.force_review = False + project_obj.save() + domain_url = get_domain_url(self.request) + project_review_list_url = "{}{}".format(domain_url, reverse("project-review-list")) + project_url = "{}{}".format(domain_url, project_obj.get_absolute_url()) + + email_context = { + "project": project_obj, + "project_url": project_url, + "project_review": project_review_obj, + "project_review_list_url": project_review_list_url, + } + + send_email_template( + "New project review has been submitted", + "email/new_project_review.txt", + email_context, + [EMAIL_DIRECTOR_EMAIL_ADDRESS], + ) + + messages.success(request, "Project reviewed successfully.") + return redirect(project_obj) + + +class ProjectReviewListView(LoginRequiredMixin, UserPassesTestMixin, ListView): model = ProjectReview - template_name = 'project/project_review_list.html' - prefetch_related = ['project', ] - context_object_name = 'project_review_list' + template_name = "project/project_review_list.html" + prefetch_related = [ + "project", + ] + context_object_name = "project_review_list" def get_queryset(self): - return ProjectReview.objects.filter(status__name='Pending') + return ProjectReview.objects.filter(status__name="Pending") def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('project.can_review_pending_project_reviews'): + if self.request.user.has_perm("project.can_review_pending_project_reviews"): return True - messages.error( - self.request, 'You do not have permission to review pending project reviews.') + messages.error(self.request, "You do not have permission to review pending project reviews.") class ProjectReviewCompleteView(LoginRequiredMixin, UserPassesTestMixin, View): login_url = "/" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('project.can_review_pending_project_reviews'): + if self.request.user.has_perm("project.can_review_pending_project_reviews"): return True - messages.error( - self.request, 'You do not have permission to mark a pending project review as completed.') + messages.error(self.request, "You do not have permission to mark a pending project review as completed.") def get(self, request, project_review_pk): - project_review_obj = get_object_or_404( - ProjectReview, pk=project_review_pk) + project_review_obj = get_object_or_404(ProjectReview, pk=project_review_pk) - project_review_status_completed_obj = ProjectReviewStatusChoice.objects.get( - name='Completed') + project_review_status_completed_obj = ProjectReviewStatusChoice.objects.get(name="Completed") project_review_obj.status = project_review_status_completed_obj project_review_obj.project.project_needs_review = False project_review_obj.save() - messages.success(request, 'Project review for {} has been completed'.format( - project_review_obj.project.title) - ) + messages.success(request, "Project review for {} has been completed".format(project_review_obj.project.title)) - return HttpResponseRedirect(reverse('project-review-list')) + return HttpResponseRedirect(reverse("project-review-list")) class ProjectReviewEmailView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = ProjectReviewEmailForm - template_name = 'project/project_review_email.html' + template_name = "project/project_review_email.html" login_url = "/" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('project.can_review_pending_project_reviews'): + if self.request.user.has_perm("project.can_review_pending_project_reviews"): return True - messages.error( - self.request, 'You do not have permission to send email for a pending project review.') + messages.error(self.request, "You do not have permission to send email for a pending project review.") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_review_obj = get_object_or_404(ProjectReview, pk=pk) - context['project_review'] = project_review_obj + context["project_review"] = project_review_obj return context @@ -1132,88 +1328,88 @@ def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" if form_class is None: form_class = self.get_form_class() - return form_class(self.kwargs.get('pk'), **self.get_form_kwargs()) + return form_class(self.kwargs.get("pk"), **self.get_form_kwargs()) def form_valid(self, form): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_review_obj = get_object_or_404(ProjectReview, pk=pk) form_data = form.cleaned_data receiver_list = [project_review_obj.project.pi.email] - cc = form_data.get('cc').strip() + cc = form_data.get("cc").strip() if cc: - cc = cc.split(',') + cc = cc.split(",") else: cc = [] send_email( - 'Request for more information', - form_data.get('email_body'), - EMAIL_DIRECTOR_EMAIL_ADDRESS, - receiver_list, - cc + "Request for more information", form_data.get("email_body"), EMAIL_DIRECTOR_EMAIL_ADDRESS, receiver_list, cc ) - messages.success(self.request, 'Email sent to {} {} ({})'.format( - project_review_obj.project.pi.first_name, - project_review_obj.project.pi.last_name, - project_review_obj.project.pi.username) + messages.success( + self.request, + "Email sent to {} {} ({})".format( + project_review_obj.project.pi.first_name, + project_review_obj.project.pi.last_name, + project_review_obj.project.pi.username, + ), ) return super().form_valid(form) def get_success_url(self): - return reverse('project-review-list') + return reverse("project-review-list") class ProjectNoteCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = ProjectUserMessage - fields = '__all__' - template_name = 'project/project_note_create.html' + fields = "__all__" + template_name = "project/project_note_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True else: - messages.error( - self.request, 'You do not have permission to add allocation notes.') + messages.error(self.request, "You do not have permission to add project notes.") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) - context['project'] = project_obj + context["project"] = project_obj return context def get_initial(self): initial = super().get_initial() - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) author = self.request.user - initial['project'] = project_obj - initial['author'] = author + initial["project"] = project_obj + initial["author"] = author return initial def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" form = super().get_form(form_class) - form.fields['project'].widget = forms.HiddenInput() - form.fields['author'].widget = forms.HiddenInput() - form.order_fields([ 'project', 'author', 'message', 'is_private' ]) + form.fields["project"].widget = forms.HiddenInput() + form.fields["author"].widget = forms.HiddenInput() + form.order_fields(["project", "author", "message", "is_private"]) return form def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.kwargs.get('pk')}) + # can probably be replaced with `return self.object.project.get_aboslute_url()` + return reverse("project-detail", kwargs={"pk": self.kwargs.get("pk")}) + class ProjectAttributeCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = ProjectAttribute form_class = ProjectAttributeAddForm - template_name = 'project/project_attribute_create.html' + template_name = "project/project_attribute_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if self.request.user.is_superuser: return True @@ -1221,44 +1417,46 @@ def test_func(self): if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error( - self.request, 'You do not have permission to add project attributes.') + messages.error(self.request, "You do not have permission to add project attributes.") def get_initial(self): initial = super().get_initial() - pk = self.kwargs.get('pk') - initial['project'] = get_object_or_404(Project, pk=pk) - initial['user'] = self.request.user + pk = self.kwargs.get("pk") + initial["project"] = get_object_or_404(Project, pk=pk) + initial["user"] = self.request.user return initial def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" form = super().get_form(form_class) - form.fields['project'].widget = forms.HiddenInput() + form.fields["project"].widget = forms.HiddenInput() return form def get_context_data(self, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") context = super().get_context_data(*args, **kwargs) - context['project'] = get_object_or_404(Project, pk=pk) + context["project"] = get_object_or_404(Project, pk=pk) return context def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.project_id}) + # can probably be replaced with `return self.object.project.get_absolute_url()` + return reverse("project-detail", kwargs={"pk": self.object.project_id}) class ProjectAttributeDeleteView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = ProjectAttribute form_class = ProjectAttributeDeleteForm - template_name = 'project/project_attribute_delete.html' + template_name = "project/project_attribute_delete.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if self.request.user.is_superuser: return True @@ -1266,88 +1464,71 @@ def test_func(self): if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error( - self.request, 'You do not have permission to add project attributes.') + messages.error(self.request, "You do not have permission to add project attributes.") def get_avail_attrs(self, project_obj): + avail_attrs = ProjectAttribute.objects.select_related("proj_attr_type").filter(project=project_obj) if not self.request.user.is_superuser: - avail_attrs = ProjectAttribute.objects.filter(project=project_obj, proj_attr_type__is_private=False) - else: - avail_attrs = ProjectAttribute.objects.filter(project=project_obj) + avail_attrs = avail_attrs.filter(proj_attr_type__is_private=False) avail_attrs_dicts = [ - { - 'pk' : attr.pk, - 'selected' : False, - 'name' : str(attr.proj_attr_type), - 'value' : attr.value - } - + {"pk": attr.pk, "selected": False, "name": str(attr.proj_attr_type), "value": attr.value} for attr in avail_attrs ] return avail_attrs_dicts def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) - project_attributes_to_delete = self.get_avail_attrs( - project_obj) + project_attributes_to_delete = self.get_avail_attrs(project_obj) context = {} if project_attributes_to_delete: - formset = formset_factory(ProjectAttributeDeleteForm, max_num=len( - project_attributes_to_delete)) - formset = formset( - initial=project_attributes_to_delete, prefix='attributeform') - context['formset'] = formset - context['project'] = project_obj + formset = formset_factory(ProjectAttributeDeleteForm, max_num=len(project_attributes_to_delete)) + formset = formset(initial=project_attributes_to_delete, prefix="attributeform") + context["formset"] = formset + context["project"] = project_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") attr_to_delete = self.get_avail_attrs(pk) - formset = formset_factory( - ProjectAttributeDeleteForm, - max_num=len(attr_to_delete) - ) - formset = formset( - request.POST, - initial=attr_to_delete, - prefix='attributeform' - ) + formset = formset_factory(ProjectAttributeDeleteForm, max_num=len(attr_to_delete)) + formset = formset(request.POST, initial=attr_to_delete, prefix="attributeform") attributes_deleted_count = 0 if formset.is_valid(): for form in formset: form_data = form.cleaned_data - if form_data['selected']: + if form_data["selected"]: attributes_deleted_count += 1 - proj_attr = ProjectAttribute.objects.get( - pk=form_data['pk']) + proj_attr = ProjectAttribute.objects.get(pk=form_data["pk"]) proj_attr.delete() - messages.success(request, 'Deleted {} attributes from project.'.format( - attributes_deleted_count)) + messages.success(request, "Deleted {} attributes from project.".format(attributes_deleted_count)) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": pk})) + class ProjectAttributeUpdateView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_attribute_update.html' + template_name = "project/project_attribute_update.html" def test_func(self): - """ UserPassesTestMixin Tests""" - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if self.request.user.is_superuser: return True @@ -1355,52 +1536,73 @@ def test_func(self): if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def get(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - project_attribute_pk = self.kwargs.get('project_attribute_pk') - + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + project_attribute_pk = self.kwargs.get("project_attribute_pk") if project_obj.projectattribute_set.filter(pk=project_attribute_pk).exists(): - project_attribute_obj = project_obj.projectattribute_set.get( - pk=project_attribute_pk) + project_attribute_obj = project_obj.projectattribute_set.get(pk=project_attribute_pk) project_attribute_update_form = ProjectAttributeUpdateForm( - initial={'pk': self.kwargs.get('project_attribute_pk'),'name': project_attribute_obj, 'value': project_attribute_obj.value, 'type' : project_attribute_obj.proj_attr_type}) + initial={ + "pk": self.kwargs.get("project_attribute_pk"), + "name": project_attribute_obj, + "value": project_attribute_obj.value, + "type": project_attribute_obj.proj_attr_type, + } + ) context = {} - context['project_obj'] = project_obj - context['project_attribute_update_form'] = project_attribute_update_form - context['project_attribute_obj'] = project_attribute_obj + context["project_obj"] = project_obj + context["project_attribute_update_form"] = project_attribute_update_form + context["project_attribute_obj"] = project_attribute_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - project_attribute_pk = self.kwargs.get('project_attribute_pk') + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + project_attribute_pk = self.kwargs.get("project_attribute_pk") if project_obj.projectattribute_set.filter(pk=project_attribute_pk).exists(): - project_attribute_obj = project_obj.projectattribute_set.get( - pk=project_attribute_pk) - - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot update an attribute in an archived project.') - return HttpResponseRedirect(reverse('project-attribute-update', kwargs={'pk': project_obj.pk, 'project_attribute_pk': project_attribute_obj.pk})) + project_attribute_obj = project_obj.projectattribute_set.get(pk=project_attribute_pk) + + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot update an attribute in an archived project.") + return HttpResponseRedirect( + reverse( + "project-attribute-update", + kwargs={"pk": project_obj.pk, "project_attribute_pk": project_attribute_obj.pk}, + ) + ) - project_attribute_update_form = ProjectAttributeUpdateForm(request.POST, initial={'pk': self.kwargs.get('project_attribute_pk'),}) + project_attribute_update_form = ProjectAttributeUpdateForm( + request.POST, + initial={ + "pk": self.kwargs.get("project_attribute_pk"), + }, + ) if project_attribute_update_form.is_valid(): form_data = project_attribute_update_form.cleaned_data - project_attribute_obj.value = form_data.get( - 'new_value') + project_attribute_obj.value = form_data.get("new_value") project_attribute_obj.save() - messages.success(request, 'Attribute Updated.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + messages.success(request, "Attribute Updated.") + return redirect(project_obj) else: for error in project_attribute_update_form.errors.values(): messages.error(request, error) - return HttpResponseRedirect(reverse('project-attribute-update', kwargs={'pk': project_obj.pk, 'project_attribute_pk': project_attribute_obj.pk})) + return HttpResponseRedirect( + reverse( + "project-attribute-update", + kwargs={"pk": project_obj.pk, "project_attribute_pk": project_attribute_obj.pk}, + ) + ) diff --git a/coldfront/core/publication/__init__.py b/coldfront/core/publication/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/publication/__init__.py +++ b/coldfront/core/publication/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/publication/admin.py b/coldfront/core/publication/admin.py index 9ae5fc989d..11708656f4 100644 --- a/coldfront/core/publication/admin.py +++ b/coldfront/core/publication/admin.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin @@ -6,10 +10,13 @@ @admin.register(PublicationSource) class PublicationSourceAdmin(SimpleHistoryAdmin): - list_display = ('name', 'url',) + list_display = ( + "name", + "url", + ) @admin.register(Publication) class PublicationAdmin(SimpleHistoryAdmin): - list_display = ('title', 'author', 'journal', 'year') - search_fields = ('project__pi__username', 'project__pi__last_name', 'title') + list_display = ("title", "author", "journal", "year") + search_fields = ("project__pi__username", "project__pi__last_name", "title") diff --git a/coldfront/core/publication/apps.py b/coldfront/core/publication/apps.py index aafa0bf636..41ba13b33f 100644 --- a/coldfront/core/publication/apps.py +++ b/coldfront/core/publication/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class PublicationConfig(AppConfig): - name = 'coldfront.core.publication' + name = "coldfront.core.publication" diff --git a/coldfront/core/publication/forms.py b/coldfront/core/publication/forms.py index 773c0fdaf6..ae9c1a5ae7 100644 --- a/coldfront/core/publication/forms.py +++ b/coldfront/core/publication/forms.py @@ -1,6 +1,8 @@ -from django import forms +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later -from coldfront.core.publication.models import PublicationSource +from django import forms class PublicationAddForm(forms.Form): @@ -12,12 +14,11 @@ class PublicationAddForm(forms.Form): class PublicationSearchForm(forms.Form): - search_id = forms.CharField( - label='Search ID', widget=forms.Textarea, required=True) + search_id = forms.CharField(label="Search ID", widget=forms.Textarea, required=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['search_id'].help_text = '
Enter ID such as DOI or Bibliographic Code to search.' + self.fields["search_id"].help_text = "
Enter ID such as DOI or Bibliographic Code to search." class PublicationResultForm(forms.Form): @@ -37,7 +38,7 @@ class PublicationDeleteForm(forms.Form): class PublicationExportForm(forms.Form): - title = forms.CharField(max_length=255, disabled=True) - year = forms.CharField(max_length=30, disabled=True) - unique_id = forms.CharField(max_length=255, disabled=True) - selected = forms.BooleanField(initial=False, required=False) + title = forms.CharField(max_length=255, disabled=True) + year = forms.CharField(max_length=30, disabled=True) + unique_id = forms.CharField(max_length=255, disabled=True) + selected = forms.BooleanField(initial=False, required=False) diff --git a/coldfront/core/publication/management/__init__.py b/coldfront/core/publication/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/publication/management/__init__.py +++ b/coldfront/core/publication/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/publication/management/commands/__init__.py b/coldfront/core/publication/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/publication/management/commands/__init__.py +++ b/coldfront/core/publication/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/publication/management/commands/add_default_publication_sources.py b/coldfront/core/publication/management/commands/add_default_publication_sources.py index 186b829f94..dd38b380c9 100644 --- a/coldfront/core/publication/management/commands/add_default_publication_sources.py +++ b/coldfront/core/publication/management/commands/add_default_publication_sources.py @@ -1,4 +1,6 @@ -import os +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later from django.core.management.base import BaseCommand @@ -6,12 +8,12 @@ class Command(BaseCommand): - help = 'Add default project related choices' + help = "Add default project related choices" def handle(self, *args, **options): PublicationSource.objects.all().delete() for name, url in [ - ('doi', 'https://doi.org/'), - ('manual', None), - ]: + ("doi", "https://doi.org/"), + ("manual", None), + ]: PublicationSource.objects.get_or_create(name=name, url=url) diff --git a/coldfront/core/publication/migrations/0001_initial.py b/coldfront/core/publication/migrations/0001_initial.py index e13e2b8f1a..a51110ad46 100644 --- a/coldfront/core/publication/migrations/0001_initial.py +++ b/coldfront/core/publication/migrations/0001_initial.py @@ -1,78 +1,155 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('project', '0001_initial'), + ("project", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='PublicationSource', + name="PublicationSource", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=255)), - ('url', models.URLField()), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=255)), + ("url", models.URLField()), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='HistoricalPublication', + name="HistoricalPublication", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=1024)), - ('author', models.CharField(max_length=1024)), - ('year', models.PositiveIntegerField()), - ('unique_id', models.CharField(blank=True, max_length=255, null=True)), - ('status', models.CharField(choices=[('Active', 'Active'), ('Archived', 'Archived')], default='Active', max_length=16)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), - ('source', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='publication.PublicationSource')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(max_length=1024)), + ("author", models.CharField(max_length=1024)), + ("year", models.PositiveIntegerField()), + ("unique_id", models.CharField(blank=True, max_length=255, null=True)), + ( + "status", + models.CharField( + choices=[("Active", "Active"), ("Archived", "Archived")], default="Active", max_length=16 + ), + ), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), + ( + "source", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="publication.PublicationSource", + ), + ), ], options={ - 'verbose_name': 'historical publication', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical publication", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='Publication', + name="Publication", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=1024)), - ('author', models.CharField(max_length=1024)), - ('year', models.PositiveIntegerField()), - ('unique_id', models.CharField(blank=True, max_length=255, null=True)), - ('status', models.CharField(choices=[('Active', 'Active'), ('Archived', 'Archived')], default='Active', max_length=16)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), - ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='publication.PublicationSource')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(max_length=1024)), + ("author", models.CharField(max_length=1024)), + ("year", models.PositiveIntegerField()), + ("unique_id", models.CharField(blank=True, max_length=255, null=True)), + ( + "status", + models.CharField( + choices=[("Active", "Active"), ("Archived", "Archived")], default="Active", max_length=16 + ), + ), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), + ( + "source", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="publication.PublicationSource"), + ), ], options={ - 'unique_together': {('project', 'unique_id')}, + "unique_together": {("project", "unique_id")}, }, ), ] diff --git a/coldfront/core/publication/migrations/0002_auto_20191223_1115.py b/coldfront/core/publication/migrations/0002_auto_20191223_1115.py index 33833567e1..5ddfa6f927 100644 --- a/coldfront/core/publication/migrations/0002_auto_20191223_1115.py +++ b/coldfront/core/publication/migrations/0002_auto_20191223_1115.py @@ -1,25 +1,28 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2019-12-23 11:15 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('publication', '0001_initial'), + ("publication", "0001_initial"), ] operations = [ migrations.AddField( - model_name='historicalpublication', - name='journal', - field=models.CharField(default='', max_length=1024), + model_name="historicalpublication", + name="journal", + field=models.CharField(default="", max_length=1024), preserve_default=False, ), migrations.AddField( - model_name='publication', - name='journal', - field=models.CharField(default='', max_length=1024), + model_name="publication", + name="journal", + field=models.CharField(default="", max_length=1024), preserve_default=False, ), ] diff --git a/coldfront/core/publication/migrations/0003_auto_20200104_1700.py b/coldfront/core/publication/migrations/0003_auto_20200104_1700.py index d5430d78b0..8c13cd568d 100644 --- a/coldfront/core/publication/migrations/0003_auto_20200104_1700.py +++ b/coldfront/core/publication/migrations/0003_auto_20200104_1700.py @@ -1,23 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2020-01-04 17:00 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('publication', '0002_auto_20191223_1115'), + ("publication", "0002_auto_20191223_1115"), ] operations = [ migrations.AlterField( - model_name='publicationsource', - name='name', + model_name="publicationsource", + name="name", field=models.CharField(max_length=255, unique=True), ), migrations.AlterField( - model_name='publicationsource', - name='url', + model_name="publicationsource", + name="url", field=models.URLField(blank=True, null=True), ), ] diff --git a/coldfront/core/publication/migrations/0004_add_manual_publication_source.py b/coldfront/core/publication/migrations/0004_add_manual_publication_source.py index 2e9d48a5d5..8563366955 100644 --- a/coldfront/core/publication/migrations/0004_add_manual_publication_source.py +++ b/coldfront/core/publication/migrations/0004_add_manual_publication_source.py @@ -1,19 +1,23 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2020-01-04 21:56 -from django.db import migrations, models +from django.db import migrations def add_manual_publication_source(apps, schema_editor): - PublicationSource = apps.get_model('publication', 'PublicationSource') + PublicationSource = apps.get_model("publication", "PublicationSource") for name, url in [ - ('manual', None), - ]: + ("manual", None), + ]: PublicationSource.objects.get_or_create(name=name, url=url) -class Migration(migrations.Migration): +class Migration(migrations.Migration): dependencies = [ - ('publication', '0003_auto_20200104_1700'), + ("publication", "0003_auto_20200104_1700"), ] operations = [ diff --git a/coldfront/core/publication/migrations/0005_alter_historicalpublication_options_and_more.py b/coldfront/core/publication/migrations/0005_alter_historicalpublication_options_and_more.py new file mode 100644 index 0000000000..82bcae6eca --- /dev/null +++ b/coldfront/core/publication/migrations/0005_alter_historicalpublication_options_and_more.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Generated by Django 4.2.23 on 2025-10-17 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("publication", "0004_add_manual_publication_source"), + ] + + operations = [ + migrations.AlterModelOptions( + name="historicalpublication", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical publication", + "verbose_name_plural": "historical publications", + }, + ), + migrations.AlterField( + model_name="historicalpublication", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + ] diff --git a/coldfront/core/publication/migrations/__init__.py b/coldfront/core/publication/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/publication/migrations/__init__.py +++ b/coldfront/core/publication/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/publication/models.py b/coldfront/core/publication/models.py index a034ca8aec..945a376598 100644 --- a/coldfront/core/publication/models.py +++ b/coldfront/core/publication/models.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.db import models from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords @@ -6,8 +10,8 @@ class PublicationSource(TimeStampedModel): - """ A publication source is a source that a publication is cited/ derived from. Examples include doi and adsabs. - + """A publication source is a source that a publication is cited/ derived from. Examples include doi and adsabs. + Attributes: name (str): source name url (URL): links to the url of the source @@ -21,8 +25,8 @@ def __str__(self): class Publication(TimeStampedModel): - """ A publication source is a source that a publication is cited/ derived from. Examples include doi and adsabs. - + """A publication source is a source that a publication is cited/ derived from. Examples include doi and adsabs. + Attributes: project (Project): links the publication to its project title (str): publication title @@ -42,15 +46,14 @@ class Publication(TimeStampedModel): unique_id = models.CharField(max_length=255, null=True, blank=True) source = models.ForeignKey(PublicationSource, on_delete=models.CASCADE) STATUS_CHOICES = ( - ('Active', 'Active'), - ('Archived', 'Archived'), + ("Active", "Active"), + ("Archived", "Archived"), ) - status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='Active') + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="Active") history = HistoricalRecords() - class Meta: - unique_together = ('project', 'unique_id') + unique_together = ("project", "unique_id") def __str__(self): return self.title diff --git a/coldfront/core/publication/templates/publication/publication_add_publication_search.html b/coldfront/core/publication/templates/publication/publication_add_publication_search.html index 7ba060f686..f4eb5e29b8 100644 --- a/coldfront/core/publication/templates/publication/publication_add_publication_search.html +++ b/coldfront/core/publication/templates/publication/publication_add_publication_search.html @@ -18,7 +18,9 @@

Add publication to project: {{project.title}}

{% csrf_token %} {{ publication_search_form|crispy }} - +
+ +
@@ -26,17 +28,16 @@

Add publication to project: {{project.title}}

{% endblock %} {% block javascript %} - {{ block.super }} +{{ block.super }} {% endblock %} diff --git a/coldfront/core/publication/templates/publication/publication_add_publication_search_result.html b/coldfront/core/publication/templates/publication/publication_add_publication_search_result.html index 08056847f4..5d6bd4f040 100644 --- a/coldfront/core/publication/templates/publication/publication_add_publication_search_result.html +++ b/coldfront/core/publication/templates/publication/publication_add_publication_search_result.html @@ -10,7 +10,7 @@ - + # Publication @@ -24,7 +24,7 @@ {{ forloop.counter }} Title: {{ form.title.value }}
- Author: {{ form.author.value }}
+ Author: {{ form.author.value }}
Year: {{ form.year.value }}
Journal: {{ form.journal.value }} @@ -36,7 +36,7 @@
{{ formset.management_form }}
- + Back to Project diff --git a/coldfront/core/publication/templates/publication/publication_delete_publications.html b/coldfront/core/publication/templates/publication/publication_delete_publications.html index 576c3c7c44..f8df61f3de 100644 --- a/coldfront/core/publication/templates/publication/publication_delete_publications.html +++ b/coldfront/core/publication/templates/publication/publication_delete_publications.html @@ -23,7 +23,7 @@

Delete publications from project: {{project.title}}

- + Title Year @@ -56,8 +56,12 @@

Delete publications from project: {{project.title}}

{% endif %} +{% endblock %} + +{% block javascript %} +{{ block.super }} {% endblock %} diff --git a/coldfront/core/publication/templates/publication/publication_export_publications.html b/coldfront/core/publication/templates/publication/publication_export_publications.html index 83b9df545f..2d061ff638 100644 --- a/coldfront/core/publication/templates/publication/publication_export_publications.html +++ b/coldfront/core/publication/templates/publication/publication_export_publications.html @@ -22,7 +22,7 @@

Export publications from project: {{project.title}}

- + Title Year @@ -55,7 +55,12 @@

Export publications from project: {{project.title}}

{% endif %} +{% endblock %} + +{% block javascript %} +{{ block.super }} + {% endblock %} diff --git a/coldfront/core/publication/tests/__init__.py b/coldfront/core/publication/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/publication/tests.py b/coldfront/core/publication/tests/tests.py similarity index 75% rename from coldfront/core/publication/tests.py rename to coldfront/core/publication/tests/tests.py index cd71f34929..249840082a 100644 --- a/coldfront/core/publication/tests.py +++ b/coldfront/core/publication/tests/tests.py @@ -1,21 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import contextlib import itertools -from unittest.mock import Mock, sentinel, patch +from unittest.mock import Mock, patch, sentinel + import bibtexparser.bibdatabase import bibtexparser.bparser -from django.test import TestCase import doi2bib +from django.test import TestCase +import coldfront.core.publication +from coldfront.core.publication.models import Publication +from coldfront.core.publication.views import PublicationSearchResultView +from coldfront.core.test_helpers.decorators import ( + makes_remote_requests, +) from coldfront.core.test_helpers.factories import ( ProjectFactory, PublicationSourceFactory, ) -from coldfront.core.test_helpers.decorators import ( - makes_remote_requests, -) -from coldfront.core.publication.models import Publication -from coldfront.core.publication.views import PublicationSearchResultView -import coldfront.core.publication class TestPublication(TestCase): @@ -27,27 +32,27 @@ def __init__(self): source = PublicationSourceFactory() self.initial_fields = { - 'project': project, - 'title': 'Test publication!', - 'author': 'coldfront et al.', - 'year': 1, - 'journal': 'Wall of the North', - 'unique_id': '5/10/20', - 'source': source, - 'status': 'Active', + "project": project, + "title": "Test publication!", + "author": "coldfront et al.", + "year": 1, + "journal": "Wall of the North", + "unique_id": "5/10/20", + "source": source, + "status": "Active", } self.unsaved_publication = Publication(**self.initial_fields) self.journals = [ - 'First academic journal of the world', - 'Second academic journal of the world', - 'New age journal', + "First academic journal of the world", + "Second academic journal of the world", + "New age journal", ] def setUp(self): self.data = self.Data() - self.unique_id_generator = ('unique_id_{}'.format(id) for id in itertools.count()) + self.unique_id_generator = ("unique_id_{}".format(id) for id in itertools.count()) def test_fields_generic(self): self.assertEqual(0, len(Publication.objects.all())) @@ -89,12 +94,12 @@ def test_journal_unique_publications(self): for journal in journals: with self.subTest(item=journal): these_fields = fields.copy() - these_fields['journal'] = journal - these_fields['unique_id'] = next(self.unique_id_generator) + these_fields["journal"] = journal + these_fields["unique_id"] = next(self.unique_id_generator) pub, created = Publication.objects.get_or_create(**these_fields) self.assertEqual(True, created) - self.assertEqual(these_fields['journal'], pub.journal) + self.assertEqual(these_fields["journal"], pub.journal) all_pubs = Publication.objects.all() self.assertEqual(len(journals), len(all_pubs)) @@ -105,31 +110,30 @@ class TestDataRetrieval(TestCase): class Data: """Collection of test data, separated for readability""" - NO_JOURNAL_INFO_FROM_DOI = '[no journal info from DOI]' + NO_JOURNAL_INFO_FROM_DOI = "[no journal info from DOI]" def __init__(self): self.expected_pubdata = [ { - 'unique_id': '10.1038/s41524-017-0032-0', - 'title': 'Construction of ground-state preserving sparse lattice models for predictive materials simulations', - 'author': 'Wenxuan Huang and Alexander Urban and Ziqin Rong and Zhiwei Ding and Chuan Luo and Gerbrand Ceder', - 'year': '2017', - 'journal': 'npj Computational Materials', + "unique_id": "10.1038/s41524-017-0032-0", + "title": "Construction of ground-state preserving sparse lattice models for predictive materials simulations", + "author": "Wenxuan Huang and Alexander Urban and Ziqin Rong and Zhiwei Ding and Chuan Luo and Gerbrand Ceder", + "year": "2017", + "journal": "npj Computational Materials", }, { - 'unique_id': '10.1145/2484762.2484798', - 'title': 'The institute for cyber-enabled research', - 'author': 'Dirk Colbry and Bill Punch and Wolfgang Bauer', - 'year': '2013', - 'journal': self.NO_JOURNAL_INFO_FROM_DOI, - + "unique_id": "10.1145/2484762.2484798", + "title": "The institute for cyber-enabled research", + "author": "Dirk Colbry and Bill Punch and Wolfgang Bauer", + "year": "2013", + "journal": self.NO_JOURNAL_INFO_FROM_DOI, }, ] # everything we might test will use this source source = PublicationSourceFactory() for pubdata_dict in self.expected_pubdata: - pubdata_dict['source_pk'] = source.pk + pubdata_dict["source_pk"] = source.pk class Mocks: """Set of mocks for testing, for simplified setup in test cases @@ -148,6 +152,7 @@ def mock_get_bib(unique_id): # ensure specified unique_id is used here if unique_id == self._unique_id: return sentinel.status, sentinel.bib_str + crossref = Mock(spec_set=doi2bib.crossref) crossref.get_bib.side_effect = mock_get_bib @@ -158,11 +163,12 @@ def mock_parse(thing_to_parse): db = bibdatabase_cls() db.entries = [self._bibdatabase_first_entry.copy()] return db + bibtexparser_cls = Mock(spec_set=bibtexparser.bparser.BibTexParser) bibtexparser_cls.return_value.parse.side_effect = mock_parse as_text = Mock(spec_set=bibtexparser.bibdatabase.as_text) - as_text.side_effect = lambda bib_entry: 'as_text({})'.format(bib_entry) + as_text.side_effect = lambda bib_entry: "as_text({})".format(bib_entry) self.crossref = crossref self.bibtexparser_cls = bibtexparser_cls @@ -172,13 +178,13 @@ def mock_parse(thing_to_parse): def patch(self): def dotpath(qualname): module_under_test = coldfront.core.publication.views - return '{}.{}'.format(module_under_test.__name__, qualname) + return "{}.{}".format(module_under_test.__name__, qualname) with contextlib.ExitStack() as stack: patches = [ - patch(dotpath('BibTexParser'), new=self.bibtexparser_cls), - patch(dotpath('crossref'), new=self.crossref), - patch(dotpath('as_text'), new=self.as_text), + patch(dotpath("BibTexParser"), new=self.bibtexparser_cls), + patch(dotpath("crossref"), new=self.crossref), + patch(dotpath("as_text"), new=self.as_text), ] for p in patches: stack.enter_context(p) @@ -201,7 +207,7 @@ def test_doi_retrieval(self): self.assertNotEqual(0, len(expected_pubdata)) # check assumption for pubdata_dict in expected_pubdata: - unique_id = pubdata_dict['unique_id'] + unique_id = pubdata_dict["unique_id"] with self.subTest(unique_id=unique_id): retrieved_data = self.run_target_method(unique_id) self.assertEqual(pubdata_dict, retrieved_data) @@ -211,23 +217,23 @@ def test_doi_extraction(self): testdata = pubdata.copy() # several adjustments required, below # test cases with NO_JOURNAL_INFO_FROM_DOI need more setup, done later in context - is_nojournal_test = testdata['journal'] == self.data.NO_JOURNAL_INFO_FROM_DOI + is_nojournal_test = testdata["journal"] == self.data.NO_JOURNAL_INFO_FROM_DOI # mutate test data so that it's definitely nonrealistic, thus # assuring that we *are* mocking the right stuff - for k in (k for k in testdata if k != 'source_pk'): - testdata[k] += '[not real]' + for k in (k for k in testdata if k != "source_pk"): + testdata[k] += "[not real]" - unique_id = testdata['unique_id'] + unique_id = testdata["unique_id"] # source_pk doesn't pertain to data returned from the remote api mocked_bibdatabase_entry = testdata.copy() - del mocked_bibdatabase_entry['source_pk'] + del mocked_bibdatabase_entry["source_pk"] # for no-journal tests, we emulate not having any 'journal' key in # data returned from remote api if is_nojournal_test: - del mocked_bibdatabase_entry['journal'] + del mocked_bibdatabase_entry["journal"] mocks = self.Mocks(mocked_bibdatabase_entry, unique_id) @@ -236,9 +242,9 @@ def test_doi_extraction(self): mock_as_text = mocks.as_text.side_effect # we expect `as_text` to be run on... - as_text_expected_on = ['author', 'title', 'year'] + as_text_expected_on = ["author", "title", "year"] if not is_nojournal_test: - as_text_expected_on.append('journal') + as_text_expected_on.append("journal") for key in as_text_expected_on: transformed = mock_as_text(expected_data[key]) @@ -251,7 +257,7 @@ def test_doi_extraction(self): # for no-journal tests, we expect a special string if is_nojournal_test: - expected_data['journal'] = self.data.NO_JOURNAL_INFO_FROM_DOI + expected_data["journal"] = self.data.NO_JOURNAL_INFO_FROM_DOI # finally done with setup... now to run the test... with self.subTest(unique_id=unique_id): diff --git a/coldfront/core/publication/urls.py b/coldfront/core/publication/urls.py index 8da5660175..2fa678ef90 100644 --- a/coldfront/core/publication/urls.py +++ b/coldfront/core/publication/urls.py @@ -1,12 +1,41 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.publication.views as publication_views urlpatterns = [ - path('publication-search//', publication_views.PublicationSearchView.as_view(), name='publication-search'), - path('publication-search-result//', publication_views.PublicationSearchResultView.as_view(), name='publication-search-result'), - path('add-publication//', publication_views.PublicationAddView.as_view(), name='add-publication'), - path('add-publication-manually//', publication_views.PublicationAddManuallyView.as_view(), name='add-publication-manually'), - path('project//delete-publications/', publication_views.PublicationDeletePublicationsView.as_view(), name='publication-delete-publications'), - path('project//export-publications/', publication_views.PublicationExportPublicationsView.as_view(), name='publication-export-publications'), + path( + "publication-search//", + publication_views.PublicationSearchView.as_view(), + name="publication-search", + ), + path( + "publication-search-result//", + publication_views.PublicationSearchResultView.as_view(), + name="publication-search-result", + ), + path("add-publication//", publication_views.PublicationAddView.as_view(), name="add-publication"), + path( + "add-publication-manually//", + publication_views.PublicationAddManuallyView.as_view(), + name="add-publication-manually", + ), + path( + "project//delete-publications/", + publication_views.PublicationDeletePublicationsView.as_view(), + name="publication-delete-publications", + ), + path( + "project//export-publications/", + publication_views.PublicationExportPublicationsView.as_view(), + name="publication-export-publications", + ), + path( + "data/by-year", + publication_views.PublicationByYearView.as_view(), + name="publications-by-year", + ), ] diff --git a/coldfront/core/publication/views.py b/coldfront/core/publication/views.py index 4d9dd5cf4b..771f218168 100644 --- a/coldfront/core/publication/views.py +++ b/coldfront/core/publication/views.py @@ -1,104 +1,108 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import ast +import io import re import uuid + import requests -import os -import io -from io import StringIO from bibtexparser.bibdatabase import as_text from bibtexparser.bparser import BibTexParser from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.db import IntegrityError +from django.db.models import Count from django.forms import formset_factory -from django.http import HttpResponseRedirect, HttpResponse +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse -from django.views.generic import DetailView, ListView, TemplateView, View +from django.views.generic import TemplateView, View from django.views.generic.edit import FormView -from django.views.static import serve +from doi2bib import crossref from coldfront.core.project.models import Project from coldfront.core.publication.forms import ( PublicationAddForm, PublicationDeleteForm, + PublicationExportForm, PublicationResultForm, PublicationSearchForm, - PublicationExportForm, ) from coldfront.core.publication.models import Publication, PublicationSource -from doi2bib import crossref - -MANUAL_SOURCE = 'manual' +MANUAL_SOURCE = "manual" class PublicationSearchView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'publication/publication_add_publication_search.html' + template_name = "publication/publication_add_publication_search.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot add publications to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add publications to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['publication_search_form'] = PublicationSearchForm() - context['project'] = Project.objects.get( - pk=self.kwargs.get('project_pk')) + context["publication_search_form"] = PublicationSearchForm() + context["project"] = Project.objects.get(pk=self.kwargs.get("project_pk")) return context class PublicationSearchResultView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'publication/publication_add_publication_search_result.html' + template_name = "publication/publication_add_publication_search_result.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot add publications to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'project_pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add publications to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"project_pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def _search_id(self, unique_id): matching_source_obj = None for source in PublicationSource.objects.all(): - if source.name == 'doi': + if source.name == "doi": try: status, bib_str = crossref.get_bib(unique_id) bp = BibTexParser(interpolate_strings=False) @@ -106,57 +110,79 @@ def _search_id(self, unique_id): bib_json = bib_database.entries[0] matching_source_obj = source break - except: + except Exception: continue - elif source.name == 'adsabs': + elif source.name == "adsabs": try: - url = 'http://adsabs.harvard.edu/cgi-bin/nph-bib_query?bibcode={}&data_type=BIBTEX'.format( - unique_id) + url = "http://adsabs.harvard.edu/cgi-bin/nph-bib_query?bibcode={}&data_type=BIBTEX".format( + unique_id + ) r = requests.get(url, timeout=5) bp = BibTexParser(interpolate_strings=False) bib_database = bp.parse(r.text) bib_json = bib_database.entries[0] matching_source_obj = source break - except: + except Exception: continue if not matching_source_obj: return False - year = as_text(bib_json['year']) - author = as_text(bib_json['author']).replace('{\\textquotesingle}', "'").replace('{\\textendash}', '-').replace( - '{\\textemdash}', '-').replace('{\\textasciigrave}', ' ').replace('{\\textdaggerdbl}', ' ').replace('{\\textdagger}', ' ') - title = as_text(bib_json['title']).replace('{\\textquotesingle}', "'").replace('{\\textendash}', '-').replace( - '{\\textemdash}', '-').replace('{\\textasciigrave}', ' ').replace('{\\textdaggerdbl}', ' ').replace('{\\textdagger}', ' ') + year = as_text(bib_json["year"]) + author = ( + as_text(bib_json["author"]) + .replace("{\\textquotesingle}", "'") + .replace("{\\textendash}", "-") + .replace("{\\textemdash}", "-") + .replace("{\\textasciigrave}", " ") + .replace("{\\textdaggerdbl}", " ") + .replace("{\\textdagger}", " ") + ) + title = ( + as_text(bib_json["title"]) + .replace("{\\textquotesingle}", "'") + .replace("{\\textendash}", "-") + .replace("{\\textemdash}", "-") + .replace("{\\textasciigrave}", " ") + .replace("{\\textdaggerdbl}", " ") + .replace("{\\textdagger}", " ") + ) author = re.sub("{|}", "", author) title = re.sub("{|}", "", title) # not all bibtex entries will have a journal field - if 'journal' in bib_json: - journal = as_text(bib_json['journal']).replace('{\\textquotesingle}', "'").replace('{\\textendash}', '-').replace( - '{\\textemdash}', '-').replace('{\\textasciigrave}', ' ').replace('{\\textdaggerdbl}', ' ').replace('{\\textdagger}', ' ') + if "journal" in bib_json: + journal = ( + as_text(bib_json["journal"]) + .replace("{\\textquotesingle}", "'") + .replace("{\\textendash}", "-") + .replace("{\\textemdash}", "-") + .replace("{\\textasciigrave}", " ") + .replace("{\\textdaggerdbl}", " ") + .replace("{\\textdagger}", " ") + ) journal = re.sub("{|}", "", journal) else: # fallback: clearly indicate that data was absent source_name = matching_source_obj.name - journal = '[no journal info from {}]'.format(source_name.upper()) + journal = "[no journal info from {}]".format(source_name.upper()) pub_dict = {} - pub_dict['author'] = author - pub_dict['year'] = year - pub_dict['title'] = title - pub_dict['journal'] = journal - pub_dict['unique_id'] = unique_id - pub_dict['source_pk'] = matching_source_obj.pk + pub_dict["author"] = author + pub_dict["year"] = year + pub_dict["title"] = title + pub_dict["journal"] = journal + pub_dict["unique_id"] = unique_id + pub_dict["source_pk"] = matching_source_obj.pk return pub_dict def post(self, request, *args, **kwargs): - search_ids = list(set(request.POST.get('search_id').split())) - project_pk = self.kwargs.get('project_pk') + search_ids = list(set(request.POST.get("search_id").split())) + project_pk = self.kwargs.get("project_pk") project_obj = get_object_or_404(Project, pk=project_pk) pubs = [] @@ -166,134 +192,143 @@ def post(self, request, *args, **kwargs): pubs.append(pub_dict) formset = formset_factory(PublicationResultForm, max_num=len(pubs)) - formset = formset(initial=pubs, prefix='pubform') + formset = formset(initial=pubs, prefix="pubform") context = {} - context['project_pk'] = project_obj.pk - context['formset'] = formset - context['search_ids'] = search_ids - context['pubs'] = pubs + context["project_pk"] = project_obj.pk + context["formset"] = formset + context["search_ids"] = search_ids + context["pubs"] = pubs return render(request, self.template_name, context) class PublicationAddView(LoginRequiredMixin, UserPassesTestMixin, View): - def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot add publications to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add publications to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): - pubs = ast.literal_eval(request.POST.get('pubs')) - project_pk = self.kwargs.get('project_pk') + pubs = ast.literal_eval(request.POST.get("pubs")) + project_pk = self.kwargs.get("project_pk") project_obj = get_object_or_404(Project, pk=project_pk) formset = formset_factory(PublicationResultForm, max_num=len(pubs)) - formset = formset(request.POST, initial=pubs, prefix='pubform') + formset = formset(request.POST, initial=pubs, prefix="pubform") publications_added = 0 publications_skipped = [] if formset.is_valid(): for form in formset: form_data = form.cleaned_data - - if form_data['selected']: - source_obj = PublicationSource.objects.get( - pk=form_data.get('source_pk')) - author = form_data.get('author') - if len(author) > 1024: author = author[:1024] + + if form_data["selected"]: + source_obj = PublicationSource.objects.get(pk=form_data.get("source_pk")) + author = form_data.get("author") + if len(author) > 1024: + author = author[:1024] publication_obj, created = Publication.objects.get_or_create( project=project_obj, - unique_id=form_data.get('unique_id'), - defaults = { - 'title':form_data.get('title'), - 'author':author, - 'year':form_data.get('year'), - 'journal':form_data.get('journal'), - 'source':source_obj - } + unique_id=form_data.get("unique_id"), + defaults={ + "title": form_data.get("title"), + "author": author, + "year": form_data.get("year"), + "journal": form_data.get("journal"), + "source": source_obj, + }, ) if created: publications_added += 1 else: - publications_skipped.append(form_data.get('unique_id')) + publications_skipped.append(form_data.get("unique_id")) - msg = '' + msg = "" if publications_added: - msg += 'Added {} publication{} to project.'.format( - publications_added, 's' if publications_added > 1 else '') + msg += "Added {} publication{} to project.".format( + publications_added, "s" if publications_added > 1 else "" + ) if publications_skipped: - msg += 'Publication already exists on this project. Skipped adding: {}'.format( - ', '.join(publications_skipped)) + msg += "Publication already exists on this project. Skipped adding: {}".format( + ", ".join(publications_skipped) + ) messages.success(request, msg) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_pk})) + class PublicationAddManuallyView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = PublicationAddForm - template_name = 'publication/publication_add_publication_manually.html' + template_name = "publication/publication_add_publication_manually.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error(self.request, 'You do not have permission to add a new publication to this project.') + messages.error(self.request, "You do not have permission to add a new publication to this project.") def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error(request, 'You cannot add publications to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add publications to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def get_initial(self): initial = super().get_initial() - initial['source'] = MANUAL_SOURCE + initial["source"] = MANUAL_SOURCE return initial def form_valid(self, form): form_data = form.cleaned_data - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) - pub_obj = Publication.objects.create( + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + Publication.objects.create( project=project_obj, - title=form_data.get('title'), - author=form_data.get('author'), - year=form_data.get('year'), - journal=form_data.get('journal'), + title=form_data.get("title"), + author=form_data.get("author"), + year=form_data.get("year"), + journal=form_data.get("journal"), unique_id=uuid.uuid4(), source=PublicationSource.objects.get(name=MANUAL_SOURCE), ) @@ -302,181 +337,162 @@ def form_valid(self, form): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['project'] = Project.objects.get(pk=self.kwargs.get('project_pk')) + context["project"] = Project.objects.get(pk=self.kwargs.get("project_pk")) return context def get_success_url(self): - messages.success(self.request, 'Added a publication.') - return reverse('project-detail', kwargs={'pk': self.kwargs.get('project_pk')}) + messages.success(self.request, "Added a publication.") + return reverse("project-detail", kwargs={"pk": self.kwargs.get("project_pk")}) class PublicationDeletePublicationsView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'publication/publication_delete_publications.html' + template_name = "publication/publication_delete_publications.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error( - self.request, 'You do not have permission to delete publications from this project.') + messages.error(self.request, "You do not have permission to delete publications from this project.") def get_publications_to_delete(self, project_obj): - publications_do_delete = [ - {'title': publication.title, - 'year': publication.year} - for publication in project_obj.publication_set.all().order_by('-year') + {"title": publication.title, "year": publication.year} + for publication in project_obj.publication_set.all().order_by("-year") ] return publications_do_delete def get(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) publications_do_delete = self.get_publications_to_delete(project_obj) context = {} if publications_do_delete: - formset = formset_factory( - PublicationDeleteForm, max_num=len(publications_do_delete)) - formset = formset(initial=publications_do_delete, - prefix='publicationform') - context['formset'] = formset + formset = formset_factory(PublicationDeleteForm, max_num=len(publications_do_delete)) + formset = formset(initial=publications_do_delete, prefix="publicationform") + context["formset"] = formset - context['project'] = project_obj + context["project"] = project_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) publications_do_delete = self.get_publications_to_delete(project_obj) - context = {} - formset = formset_factory( - PublicationDeleteForm, max_num=len(publications_do_delete)) - formset = formset( - request.POST, initial=publications_do_delete, prefix='publicationform') + formset = formset_factory(PublicationDeleteForm, max_num=len(publications_do_delete)) + formset = formset(request.POST, initial=publications_do_delete, prefix="publicationform") publications_deleted_count = 0 if formset.is_valid(): for form in formset: publication_form_data = form.cleaned_data - if publication_form_data['selected']: - + if publication_form_data["selected"]: publication_obj = Publication.objects.get( project=project_obj, - title=publication_form_data.get('title'), - year=publication_form_data.get('year') + title=publication_form_data.get("title"), + year=publication_form_data.get("year"), ) publication_obj.delete() publications_deleted_count += 1 - messages.success(request, 'Deleted {} publications from project.'.format( - publications_deleted_count)) + messages.success(request, "Deleted {} publications from project.".format(publications_deleted_count)) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.project.id}) + return reverse("project-detail", kwargs={"pk": self.object.project.id}) class PublicationExportPublicationsView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'publication/publication_export_publications.html' + template_name = "publication/publication_export_publications.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error( - self.request, 'You do not have permission to delete publications from this project.') + messages.error(self.request, "You do not have permission to delete publications from this project.") def get_publications_to_export(self, project_obj): - publications_do_delete = [ - {'title': publication.title, - 'year': publication.year, - 'unique_id': publication.unique_id, } - for publication in project_obj.publication_set.all().order_by('-year') + { + "title": publication.title, + "year": publication.year, + "unique_id": publication.unique_id, + } + for publication in project_obj.publication_set.all().order_by("-year") ] return publications_do_delete def get(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) publications_do_export = self.get_publications_to_export(project_obj) context = {} if publications_do_export: - formset = formset_factory( - PublicationExportForm, max_num=len(publications_do_export)) - formset = formset(initial=publications_do_export, - prefix='publicationform') - context['formset'] = formset + formset = formset_factory(PublicationExportForm, max_num=len(publications_do_export)) + formset = formset(initial=publications_do_export, prefix="publicationform") + context["formset"] = formset - context['project'] = project_obj + context["project"] = project_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) publications_do_export = self.get_publications_to_export(project_obj) - context = {} - formset = formset_factory( - PublicationExportForm, max_num=len(publications_do_export)) - formset = formset( - request.POST, initial=publications_do_export, prefix='publicationform') + formset = formset_factory(PublicationExportForm, max_num=len(publications_do_export)) + formset = formset(request.POST, initial=publications_do_export, prefix="publicationform") - publications_deleted_count = 0 - bib_text = '' + bib_text = "" if formset.is_valid(): for form in formset: publication_form_data = form.cleaned_data - if publication_form_data['selected']: - + if publication_form_data["selected"]: publication_obj = Publication.objects.get( project=project_obj, - title=publication_form_data.get('title'), - year=publication_form_data.get('year'), - unique_id=publication_form_data.get('unique_id'), + title=publication_form_data.get("title"), + year=publication_form_data.get("year"), + unique_id=publication_form_data.get("unique_id"), ) - print("id is"+publication_obj.display_uid()) - temp_id = publication_obj.display_uid() + print("id is" + publication_obj.display_uid()) + publication_obj.display_uid() status, bib_str = crossref.get_bib(publication_obj.display_uid()) bp = BibTexParser(interpolate_strings=False) - bib_database = bp.parse(bib_str) + bp.parse(bib_str) bib_text += bib_str - response = HttpResponse(content_type='text/plain') - response['Content-Disposition'] = 'attachment; filename=refs.bib' + response = HttpResponse(content_type="text/plain") + response["Content-Disposition"] = "attachment; filename=refs.bib" buffer = io.StringIO() buffer.write(bib_text) output = buffer.getvalue() @@ -487,7 +503,27 @@ def post(self, request, *args, **kwargs): for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.project.id}) + return reverse("project-detail", kwargs={"pk": self.object.project.id}) + + +class PublicationByYearView(View): + def get(self, request, *args, **kwargs): + data = { + "total": 0, + "data": [], + } + for pub in list( + Publication.objects.filter(year__gte=1999) + .values("unique_id", "year") + .distinct() + .values("year") + .annotate(count=Count("year")) + .order_by("year") + ): + data["total"] += pub["count"] + data["data"].append({"name": pub["year"], "total": pub["count"]}) + + return JsonResponse(data) diff --git a/coldfront/core/research_output/__init__.py b/coldfront/core/research_output/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/research_output/__init__.py +++ b/coldfront/core/research_output/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/research_output/admin.py b/coldfront/core/research_output/admin.py index 18198b9736..bf5196d66a 100644 --- a/coldfront/core/research_output/admin.py +++ b/coldfront/core/research_output/admin.py @@ -1,32 +1,31 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin from coldfront.core.research_output.models import ResearchOutput - -_research_output_fields_for_end = ['created_by', 'project', 'created', 'modified'] +_research_output_fields_for_end = ["created_by", "project", "created", "modified"] @admin.register(ResearchOutput) class ResearchOutputAdmin(SimpleHistoryAdmin): list_display = [ - field.name for field in ResearchOutput._meta.get_fields() - if field.name not in _research_output_fields_for_end + field.name for field in ResearchOutput._meta.get_fields() if field.name not in _research_output_fields_for_end ] + _research_output_fields_for_end list_filter = ( - 'project', - 'created_by', + "project", + "created_by", ) ordering = ( - 'project', - '-created', + "project", + "-created", ) # display the noneditable fields on the "change" form - readonly_fields = [ - field.name for field in ResearchOutput._meta.get_fields() - if not field.editable - ] + readonly_fields = [field.name for field in ResearchOutput._meta.get_fields() if not field.editable] # the view implements some Add logic that we need not replicate here # to simplify: remove ability to add via admin interface diff --git a/coldfront/core/research_output/apps.py b/coldfront/core/research_output/apps.py index 2b02d013a7..75d0e8094d 100644 --- a/coldfront/core/research_output/apps.py +++ b/coldfront/core/research_output/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class ResearchOutputConfig(AppConfig): - name = 'coldfront.core.research_output' + name = "coldfront.core.research_output" diff --git a/coldfront/core/research_output/forms.py b/coldfront/core/research_output/forms.py index ca7d166658..dde2d62892 100644 --- a/coldfront/core/research_output/forms.py +++ b/coldfront/core/research_output/forms.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.forms import ModelForm from coldfront.core.research_output.models import ResearchOutput @@ -6,4 +10,6 @@ class ResearchOutputForm(ModelForm): class Meta: model = ResearchOutput - exclude = ['project', ] + exclude = [ + "project", + ] diff --git a/coldfront/core/research_output/migrations/0001_initial.py b/coldfront/core/research_output/migrations/0001_initial.py index 84e0822a60..294881725e 100644 --- a/coldfront/core/research_output/migrations/0001_initial.py +++ b/coldfront/core/research_output/migrations/0001_initial.py @@ -1,56 +1,109 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2020-02-10 03:30 -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('project', '0001_initial'), + ("project", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='ResearchOutput', + name="ResearchOutput", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(blank=True, max_length=128)), - ('description', models.TextField(validators=[django.core.validators.MinLengthValidator(3)])), - ('created', models.DateTimeField(auto_now_add=True)), - ('created_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(blank=True, max_length=128)), + ("description", models.TextField(validators=[django.core.validators.MinLengthValidator(3)])), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), ], ), migrations.CreateModel( - name='HistoricalResearchOutput', + name="HistoricalResearchOutput", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(blank=True, max_length=128)), - ('description', models.TextField(validators=[django.core.validators.MinLengthValidator(3)])), - ('created', models.DateTimeField(blank=True, editable=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('created_by', models.ForeignKey(blank=True, db_constraint=False, editable=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(blank=True, max_length=128)), + ("description", models.TextField(validators=[django.core.validators.MinLengthValidator(3)])), + ("created", models.DateTimeField(blank=True, editable=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + editable=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), ], options={ - 'verbose_name': 'historical research output', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical research output", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), diff --git a/coldfront/core/research_output/migrations/0002_alter_historicalresearchoutput_options_and_more.py b/coldfront/core/research_output/migrations/0002_alter_historicalresearchoutput_options_and_more.py new file mode 100644 index 0000000000..9113c2f058 --- /dev/null +++ b/coldfront/core/research_output/migrations/0002_alter_historicalresearchoutput_options_and_more.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Generated by Django 4.2.23 on 2025-10-17 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("research_output", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="historicalresearchoutput", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical research output", + "verbose_name_plural": "historical research outputs", + }, + ), + migrations.AlterField( + model_name="historicalresearchoutput", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + ] diff --git a/coldfront/core/research_output/migrations/__init__.py b/coldfront/core/research_output/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/research_output/migrations/__init__.py +++ b/coldfront/core/research_output/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/research_output/models.py b/coldfront/core/research_output/models.py index 92977c5984..ddbfdc765a 100644 --- a/coldfront/core/research_output/models.py +++ b/coldfront/core/research_output/models.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.contrib.auth.models import User from django.core.validators import MinLengthValidator from django.db import models -from django.contrib.auth.models import User from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords @@ -8,8 +12,8 @@ class ResearchOutput(TimeStampedModel): - """ A research output represents anything related a project that would not fall under the publication section. Examples include magazine or newspaper articles, media coverage, databases, software, or other products created. - + """A research output represents anything related a project that would not fall under the publication section. Examples include magazine or newspaper articles, media coverage, databases, software, or other products created. + Attributes: project (Project): links project to research output title (str): title of research output @@ -37,7 +41,7 @@ class ResearchOutput(TimeStampedModel): history = HistoricalRecords() def save(self, *args, **kwargs): - """ Saves the research output. """ + """Saves the research output.""" if not self.pk: # ensure that created_by is set initially - preventing most # accidental omission @@ -46,7 +50,7 @@ def save(self, *args, **kwargs): # populated by the code that adds the ResearchOutput to the # database if not self.created_by: - raise ValueError('Model INSERT must set a created_by User') + raise ValueError("Model INSERT must set a created_by User") # since title is optional, we want to simplify and standardize "no title" entries # we do this at the model layer to ensure as consistent behavior as possible diff --git a/coldfront/core/research_output/templates/research_output/research_output_create.html b/coldfront/core/research_output/templates/research_output/research_output_create.html index fe2824910f..e35f16f046 100644 --- a/coldfront/core/research_output/templates/research_output/research_output_create.html +++ b/coldfront/core/research_output/templates/research_output/research_output_create.html @@ -9,6 +9,7 @@ {% block content %} +

Creating research output for project: {{ project.title }}

{% csrf_token %} {{ form|crispy }} diff --git a/coldfront/core/research_output/tests/__init__.py b/coldfront/core/research_output/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/research_output/tests.py b/coldfront/core/research_output/tests/tests.py similarity index 86% rename from coldfront/core/research_output/tests.py rename to coldfront/core/research_output/tests/tests.py index 9e98d62024..1e2f2af4bf 100644 --- a/coldfront/core/research_output/tests.py +++ b/coldfront/core/research_output/tests/tests.py @@ -1,13 +1,17 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime + from django.core.exceptions import ValidationError -from django.db import IntegrityError from django.test import TestCase +from coldfront.core.research_output.models import ResearchOutput from coldfront.core.test_helpers.factories import ( ProjectFactory, UserFactory, ) -from coldfront.core.research_output.models import ResearchOutput class TestResearchOutput(TestCase): @@ -16,13 +20,13 @@ class Data: def __init__(self): project = ProjectFactory() - user = UserFactory(username='submitter') + user = UserFactory(username="submitter") self.initial_fields = { - 'project': project, - 'title': 'Something we made!', - 'description': 'something, really', - 'created_by': user, + "project": project, + "title": "Something we made!", + "description": "something, really", + "created_by": user, } self.unsaved_object = ResearchOutput(**self.initial_fields) @@ -50,25 +54,25 @@ def test_title_optional(self): self.assertEqual(0, len(ResearchOutput.objects.all())) research_output_obj = self.data.unsaved_object - research_output_obj.title = '' + research_output_obj.title = "" research_output_obj.save() self.assertEqual(1, len(ResearchOutput.objects.all())) retrieved_obj = ResearchOutput.objects.get(pk=research_output_obj.pk) - self.assertEqual('', retrieved_obj.title) + self.assertEqual("", retrieved_obj.title) def test_empty_title_sanitized(self): research_output_obj = self.data.unsaved_object - research_output_obj.title = ' \t\n ' + research_output_obj.title = " \t\n " research_output_obj.save() retrieved_obj = ResearchOutput.objects.get(pk=research_output_obj.pk) - self.assertEqual('', retrieved_obj.title) + self.assertEqual("", retrieved_obj.title) def test_description_minlength(self): expected_minimum_length = 3 - minimum_description = 'x' * expected_minimum_length + minimum_description = "x" * expected_minimum_length research_output_obj = self.data.unsaved_object @@ -110,7 +114,7 @@ def test_created_by_foreignkey_on_delete(self): try: retrieved_obj = ResearchOutput.objects.get(pk=research_output_obj.pk) except ResearchOutput.DoesNotExist as e: - raise self.failureException('Expected no cascade from user deletion') from e + raise self.failureException("Expected no cascade from user deletion") from e # if here, did not cascade self.assertIsNone(retrieved_obj.created_by) # null, as expected from SET_NULL diff --git a/coldfront/core/research_output/urls.py b/coldfront/core/research_output/urls.py index db78e0870e..c3236dbb83 100644 --- a/coldfront/core/research_output/urls.py +++ b/coldfront/core/research_output/urls.py @@ -1,8 +1,20 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.research_output.views as research_output_views urlpatterns = [ - path('add-research-output//', research_output_views.ResearchOutputCreateView.as_view(), name='add-research-output'), - path('project//delete-research-outputs', research_output_views.ResearchOutputDeleteResearchOutputsView.as_view(), name='research-output-delete-research-outputs'), + path( + "add-research-output//", + research_output_views.ResearchOutputCreateView.as_view(), + name="add-research-output", + ), + path( + "project//delete-research-outputs", + research_output_views.ResearchOutputDeleteResearchOutputsView.as_view(), + name="research-output-delete-research-outputs", + ), ] diff --git a/coldfront/core/research_output/views.py b/coldfront/core/research_output/views.py index dd421afe77..a53bda69b9 100644 --- a/coldfront/core/research_output/views.py +++ b/coldfront/core/research_output/views.py @@ -1,8 +1,13 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 +from django.template.defaultfilters import pluralize from django.urls import reverse from django.views.generic import CreateView, ListView @@ -10,32 +15,31 @@ from coldfront.core.research_output.forms import ResearchOutputForm from coldfront.core.research_output.models import ResearchOutput from coldfront.core.utils.mixins.views import ( - UserActiveManagerOrHigherMixin, ChangesOnlyOnActiveProjectMixin, ProjectInContextMixin, SnakeCaseTemplateNameMixin, + UserActiveManagerOrHigherMixin, ) class ResearchOutputCreateView( - UserActiveManagerOrHigherMixin, - ChangesOnlyOnActiveProjectMixin, - SuccessMessageMixin, - SnakeCaseTemplateNameMixin, - ProjectInContextMixin, - CreateView): - + UserActiveManagerOrHigherMixin, + ChangesOnlyOnActiveProjectMixin, + SuccessMessageMixin, + SnakeCaseTemplateNameMixin, + ProjectInContextMixin, + CreateView, +): # directly using the exclude option isn't possible with CreateView; use such a form instead form_class = ResearchOutputForm model = ResearchOutput - template_name_suffix = '_create' + template_name_suffix = "_create" - success_message = 'Research Output added successfully.' + success_message = "Research Output added successfully." def form_valid(self, form): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) obj = form.save(commit=False) obj.created_by = self.request.user @@ -46,42 +50,40 @@ def form_valid(self, form): return super().form_valid(form) def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.kwargs.get('project_pk')}) + return reverse("project-detail", kwargs={"pk": self.kwargs.get("project_pk")}) class ResearchOutputDeleteResearchOutputsView( - UserActiveManagerOrHigherMixin, - ChangesOnlyOnActiveProjectMixin, - SnakeCaseTemplateNameMixin, - ProjectInContextMixin, - ListView): - + UserActiveManagerOrHigherMixin, + ChangesOnlyOnActiveProjectMixin, + SnakeCaseTemplateNameMixin, + ProjectInContextMixin, + ListView, +): model = ResearchOutput # only included to utilize SnakeCaseTemplateNameMixin - template_name_suffix = '_delete_research_outputs' + template_name_suffix = "_delete_research_outputs" def get_queryset(self): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) - return ResearchOutput.objects.filter(project=project_obj).order_by('-created') + return ResearchOutput.objects.filter(project=project_obj).order_by("-created") def post(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) def get_normalized_posted_pks(): posted_pks = set(request.POST.keys()) - posted_pks.remove('csrfmiddlewaretoken') + posted_pks.remove("csrfmiddlewaretoken") return {int(x) for x in posted_pks} project_research_outputs = self.get_queryset() - project_research_output_pks = set(project_research_outputs.values_list('pk', flat=True)) + project_research_output_pks = set(project_research_outputs.values_list("pk", flat=True)) posted_research_output_pks = get_normalized_posted_pks() # make sure we're told to delete something, else error to same page if not posted_research_output_pks: - messages.error(request, 'Please select some research outputs to delete, or go back to project.') + messages.error(request, "Please select some research outputs to delete, or go back to project.") return HttpResponseRedirect(request.path_info) # make sure the user plays nice @@ -90,10 +92,7 @@ def get_normalized_posted_pks(): num_deletions, _ = project_research_outputs.filter(pk__in=posted_research_output_pks).delete() - msg = 'Deleted {} research output{} from project.'.format( - num_deletions, - '' if num_deletions == 1 else 's', - ) + msg = f"Deleted {num_deletions} research output{pluralize(num_deletions)} from project." messages.success(request, msg) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) diff --git a/coldfront/core/resource/__init__.py b/coldfront/core/resource/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/resource/__init__.py +++ b/coldfront/core/resource/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/resource/admin.py b/coldfront/core/resource/admin.py index 11a1f3cf88..34c40c2a6d 100644 --- a/coldfront/core/resource/admin.py +++ b/coldfront/core/resource/admin.py @@ -1,29 +1,61 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin -from coldfront.core.resource.models import (AttributeType, Resource, - ResourceAttribute, - ResourceAttributeType, - ResourceType) +from coldfront.core.resource.models import ( + AttributeType, + Resource, + ResourceAttribute, + ResourceAttributeType, + ResourceType, +) @admin.register(AttributeType) class AttributeTypeAdmin(admin.ModelAdmin): - list_display = ('name', 'created', 'modified', ) - search_fields = ('name', ) + list_display = ( + "name", + "created", + "modified", + ) + search_fields = ("name",) @admin.register(ResourceType) class ResourceTypeAdmin(admin.ModelAdmin): - list_display = ('name', 'description', 'created', 'modified', ) - search_fields = ('name', 'description',) + list_display = ( + "name", + "description", + "created", + "modified", + ) + search_fields = ( + "name", + "description", + ) @admin.register(ResourceAttributeType) class ResourceAttributeTypeAdmin(SimpleHistoryAdmin): - list_display = ('pk', 'name', 'attribute_type_name', 'is_required', 'is_unique_per_resource', 'is_value_unique', 'created', 'modified', ) - search_fields = ('name', 'attribute_type__name', 'resource_type__name',) - list_filter = ('attribute_type__name', 'name', 'is_required', 'is_unique_per_resource', 'is_value_unique') + list_display = ( + "pk", + "name", + "attribute_type_name", + "is_required", + "is_unique_per_resource", + "is_value_unique", + "created", + "modified", + ) + search_fields = ( + "name", + "attribute_type__name", + "resource_type__name", + ) + list_filter = ("attribute_type__name", "name", "is_required", "is_unique_per_resource", "is_value_unique") def attribute_type_name(self, obj): return obj.attribute_type.name @@ -31,7 +63,10 @@ def attribute_type_name(self, obj): class ResourceAttributeInline(admin.TabularInline): model = ResourceAttribute - fields_change = ('resource_attribute_type', 'value',) + fields_change = ( + "resource_attribute_type", + "value", + ) extra = 0 def get_fields(self, request, obj): @@ -41,18 +76,44 @@ def get_fields(self, request, obj): return self.fields_change - @admin.register(Resource) class ResourceAdmin(SimpleHistoryAdmin): # readonly_fields_change = ('resource_type', ) - fields_change = ('resource_type', 'parent_resource', 'is_allocatable', 'name', 'description', 'is_available', - 'is_public', 'requires_payment', 'allowed_groups', 'allowed_users', 'linked_resources') - list_display = ('pk', 'name', 'description', 'parent_resource', 'is_allocatable', 'resource_type_name', - 'is_available', 'is_public', 'created', 'modified', ) - search_fields = ('name', 'description', 'resource_type__name') - list_filter = ('resource_type__name', 'is_allocatable', 'is_available', 'is_public', 'requires_payment' ) - inlines = [ResourceAttributeInline, ] - filter_horizontal = ['allowed_groups', 'allowed_users', 'linked_resources', ] + fields_change = ( + "resource_type", + "parent_resource", + "is_allocatable", + "name", + "description", + "is_available", + "is_public", + "requires_payment", + "allowed_groups", + "allowed_users", + "linked_resources", + ) + list_display = ( + "pk", + "name", + "description", + "parent_resource", + "is_allocatable", + "resource_type_name", + "is_available", + "is_public", + "created", + "modified", + ) + search_fields = ("name", "description", "resource_type__name") + list_filter = ("resource_type__name", "is_allocatable", "is_available", "is_public", "requires_payment") + inlines = [ + ResourceAttributeInline, + ] + filter_horizontal = [ + "allowed_groups", + "allowed_users", + "linked_resources", + ] save_as = True def resource_type_name(self, obj): @@ -67,9 +128,16 @@ def get_fields(self, request, obj): @admin.register(ResourceAttribute) class ResourceAttributeAdmin(SimpleHistoryAdmin): - list_display = ('pk', 'resource_name', 'value', 'resource_attribute_type_name', 'created', 'modified', ) - search_fields = ('resource__name', 'resource_attribute_type__name', 'value') - list_filter = ('resource_attribute_type__name', ) + list_display = ( + "pk", + "resource_name", + "value", + "resource_attribute_type_name", + "created", + "modified", + ) + search_fields = ("resource__name", "resource_attribute_type__name", "value") + list_filter = ("resource_attribute_type__name",) def resource_name(self, obj): return obj.resource.name diff --git a/coldfront/core/resource/apps.py b/coldfront/core/resource/apps.py index 5c46681e00..e5dddec9bd 100644 --- a/coldfront/core/resource/apps.py +++ b/coldfront/core/resource/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class ResourceConfig(AppConfig): - name = 'coldfront.core.resource' + name = "coldfront.core.resource" diff --git a/coldfront/core/resource/forms.py b/coldfront/core/resource/forms.py index c8366b7d96..6a711f35b0 100644 --- a/coldfront/core/resource/forms.py +++ b/coldfront/core/resource/forms.py @@ -1,33 +1,31 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import forms +from django.db.models.functions import Lower from coldfront.core.resource.models import ResourceAttribute -from django.db.models.functions import Lower + class ResourceSearchForm(forms.Form): - """ Search form for the Resource list page. - """ - model = forms.CharField( - label='Model', max_length=100, required=False) - serialNumber = forms.CharField( - label='Serial Number', max_length=100, required=False) - vendor = forms.CharField( - label='Vendor', max_length=100, required=False) + """Search form for the Resource list page.""" + + model = forms.CharField(label="Model", max_length=100, required=False) + serialNumber = forms.CharField(label="Serial Number", max_length=100, required=False) + vendor = forms.CharField(label="Vendor", max_length=100, required=False) installDate = forms.DateField( - label='Install Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) + label="Install Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) serviceStart = forms.DateField( - label='Service Start', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) - serviceEnd = forms.DateField( - label='Service End', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) + label="Service Start", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) + serviceEnd = forms.DateField( + label="Service End", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) warrantyExpirationDate = forms.DateField( - label='Warranty Expiration Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) + label="Warranty Expiration Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) show_allocatable_resources = forms.BooleanField(initial=False, required=False) @@ -39,13 +37,16 @@ class ResourceAttributeDeleteForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() class ResourceAttributeCreateForm(forms.ModelForm): class Meta: model = ResourceAttribute - fields = '__all__' + fields = "__all__" + def __init__(self, *args, **kwargs): - super(ResourceAttributeCreateForm, self).__init__(*args, **kwargs) - self.fields['resource_attribute_type'].queryset = self.fields['resource_attribute_type'].queryset.order_by(Lower('name')) \ No newline at end of file + super(ResourceAttributeCreateForm, self).__init__(*args, **kwargs) + self.fields["resource_attribute_type"].queryset = self.fields["resource_attribute_type"].queryset.order_by( + Lower("name") + ) diff --git a/coldfront/core/resource/management/__init__.py b/coldfront/core/resource/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/resource/management/__init__.py +++ b/coldfront/core/resource/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/resource/management/commands/__init__.py b/coldfront/core/resource/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/resource/management/commands/__init__.py +++ b/coldfront/core/resource/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/resource/management/commands/add_resource_defaults.py b/coldfront/core/resource/management/commands/add_resource_defaults.py index dd29af8b5b..c93753564e 100644 --- a/coldfront/core/resource/management/commands/add_resource_defaults.py +++ b/coldfront/core/resource/management/commands/add_resource_defaults.py @@ -1,53 +1,62 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.management.base import BaseCommand -from coldfront.core.resource.models import (AttributeType, - ResourceAttributeType, - ResourceType) +from coldfront.core.resource.models import AttributeType, ResourceAttributeType, ResourceType class Command(BaseCommand): - help = 'Add default resource related choices' + help = "Add default resource related choices" def handle(self, *args, **options): - - for attribute_type in ('Active/Inactive', 'Date', 'Int', - 'Public/Private', 'Text', 'Yes/No', 'Attribute Expanded Text'): + for attribute_type in ( + "Active/Inactive", + "Date", + "Int", + "Public/Private", + "Text", + "Yes/No", + "Attribute Expanded Text", + ): AttributeType.objects.get_or_create(name=attribute_type) for resource_attribute_type, attribute_type in ( - ('Core Count', 'Int'), - ('expiry_time', 'Int'), - ('fee_applies', 'Yes/No'), - ('Node Count', 'Int'), - ('Owner', 'Text'), - ('quantity_default_value', 'Int'), - ('quantity_label', 'Text'), - ('eula', 'Text'), - ('OnDemand','Yes/No'), - ('ServiceEnd', 'Date'), - ('ServiceStart', 'Date'), - ('slurm_cluster', 'Text'), - ('slurm_specs', 'Attribute Expanded Text'), - ('slurm_specs_attriblist', 'Text'), - ('Status', 'Public/Private'), - ('Vendor', 'Text'), - ('Model', 'Text'), - ('SerialNumber', 'Text'), - ('RackUnits', 'Int'), - ('InstallDate', 'Date'), - ('WarrantyExpirationDate', 'Date'), + ("Core Count", "Int"), + ("expiry_time", "Int"), + ("fee_applies", "Yes/No"), + ("Node Count", "Int"), + ("Owner", "Text"), + ("quantity_default_value", "Int"), + ("quantity_label", "Text"), + ("eula", "Text"), + ("OnDemand", "Yes/No"), + ("ServiceEnd", "Date"), + ("ServiceStart", "Date"), + ("slurm_cluster", "Text"), + ("slurm_specs", "Attribute Expanded Text"), + ("slurm_specs_attriblist", "Text"), + ("Status", "Public/Private"), + ("Vendor", "Text"), + ("Model", "Text"), + ("SerialNumber", "Text"), + ("RackUnits", "Int"), + ("InstallDate", "Date"), + ("WarrantyExpirationDate", "Date"), + ("allocation_limit", "Int"), ): ResourceAttributeType.objects.get_or_create( - name=resource_attribute_type, attribute_type=AttributeType.objects.get(name=attribute_type)) + name=resource_attribute_type, attribute_type=AttributeType.objects.get(name=attribute_type) + ) for resource_type, description in ( - ('Cloud', 'Cloud Computing'), - ('Cluster', 'Cluster servers'), - ('Cluster Partition', 'Cluster Partition '), - ('Compute Node', 'Compute Node'), - ('Server', 'Extra servers providing various services'), - ('Software License', 'Software license purchased by users'), - ('Storage', 'NAS storage'), + ("Cloud", "Cloud Computing"), + ("Cluster", "Cluster servers"), + ("Cluster Partition", "Cluster Partition "), + ("Compute Node", "Compute Node"), + ("Server", "Extra servers providing various services"), + ("Software License", "Software license purchased by users"), + ("Storage", "NAS storage"), ): - ResourceType.objects.get_or_create( - name=resource_type, description=description) + ResourceType.objects.get_or_create(name=resource_type, description=description) diff --git a/coldfront/core/resource/migrations/0001_initial.py b/coldfront/core/resource/migrations/0001_initial.py index 8d489b7cf2..fe09953007 100644 --- a/coldfront/core/resource/migrations/0001_initial.py +++ b/coldfront/core/resource/migrations/0001_initial.py @@ -1,192 +1,398 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('auth', '0011_update_proxy_permissions'), + ("auth", "0011_update_proxy_permissions"), ] operations = [ migrations.CreateModel( - name='AttributeType', + name="AttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=128, unique=True)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=128, unique=True)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ResourceType', + name="ResourceType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=128, unique=True)), - ('description', models.CharField(max_length=255)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=128, unique=True)), + ("description", models.CharField(max_length=255)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ResourceAttributeType', + name="ResourceAttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=128)), - ('is_required', models.BooleanField(default=False)), - ('is_unique_per_resource', models.BooleanField(default=False)), - ('is_value_unique', models.BooleanField(default=False)), - ('attribute_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource.AttributeType')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=128)), + ("is_required", models.BooleanField(default=False)), + ("is_unique_per_resource", models.BooleanField(default=False)), + ("is_value_unique", models.BooleanField(default=False)), + ( + "attribute_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="resource.AttributeType"), + ), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='Resource', + name="Resource", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=128, unique=True)), - ('description', models.TextField()), - ('is_available', models.BooleanField(default=True)), - ('is_public', models.BooleanField(default=True)), - ('is_allocatable', models.BooleanField(default=True)), - ('requires_payment', models.BooleanField(default=False)), - ('allowed_groups', models.ManyToManyField(blank=True, to='auth.Group')), - ('allowed_users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), - ('linked_resources', models.ManyToManyField(blank=True, related_name='_resource_linked_resources_+', to='resource.Resource')), - ('parent_resource', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='resource.Resource')), - ('resource_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource.ResourceType')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=128, unique=True)), + ("description", models.TextField()), + ("is_available", models.BooleanField(default=True)), + ("is_public", models.BooleanField(default=True)), + ("is_allocatable", models.BooleanField(default=True)), + ("requires_payment", models.BooleanField(default=False)), + ("allowed_groups", models.ManyToManyField(blank=True, to="auth.Group")), + ("allowed_users", models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), + ( + "linked_resources", + models.ManyToManyField( + blank=True, related_name="_resource_linked_resources_+", to="resource.Resource" + ), + ), + ( + "parent_resource", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="resource.Resource" + ), + ), + ( + "resource_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="resource.ResourceType"), + ), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='HistoricalResourceType', + name="HistoricalResourceType", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(db_index=True, max_length=128)), - ('description', models.CharField(max_length=255)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(db_index=True, max_length=128)), + ("description", models.CharField(max_length=255)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical resource type', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical resource type", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalResourceAttributeType', + name="HistoricalResourceAttributeType", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=128)), - ('is_required', models.BooleanField(default=False)), - ('is_unique_per_resource', models.BooleanField(default=False)), - ('is_value_unique', models.BooleanField(default=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('attribute_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='resource.AttributeType')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=128)), + ("is_required", models.BooleanField(default=False)), + ("is_unique_per_resource", models.BooleanField(default=False)), + ("is_value_unique", models.BooleanField(default=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "attribute_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="resource.AttributeType", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical resource attribute type', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical resource attribute type", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalResourceAttribute', + name="HistoricalResourceAttribute", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=512)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('resource', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='resource.Resource')), - ('resource_attribute_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='resource.ResourceAttributeType')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=512)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "resource", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="resource.Resource", + ), + ), + ( + "resource_attribute_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="resource.ResourceAttributeType", + ), + ), ], options={ - 'verbose_name': 'historical resource attribute', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical resource attribute", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalResource', + name="HistoricalResource", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(db_index=True, max_length=128)), - ('description', models.TextField()), - ('is_available', models.BooleanField(default=True)), - ('is_public', models.BooleanField(default=True)), - ('is_allocatable', models.BooleanField(default=True)), - ('requires_payment', models.BooleanField(default=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('parent_resource', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='resource.Resource')), - ('resource_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='resource.ResourceType')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(db_index=True, max_length=128)), + ("description", models.TextField()), + ("is_available", models.BooleanField(default=True)), + ("is_public", models.BooleanField(default=True)), + ("is_allocatable", models.BooleanField(default=True)), + ("requires_payment", models.BooleanField(default=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "parent_resource", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="resource.Resource", + ), + ), + ( + "resource_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="resource.ResourceType", + ), + ), ], options={ - 'verbose_name': 'historical resource', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical resource", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='ResourceAttribute', + name="ResourceAttribute", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=512)), - ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource.Resource')), - ('resource_attribute_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource.ResourceAttributeType')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=512)), + ("resource", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="resource.Resource")), + ( + "resource_attribute_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="resource.ResourceAttributeType"), + ), ], options={ - 'unique_together': {('resource_attribute_type', 'resource')}, + "unique_together": {("resource_attribute_type", "resource")}, }, ), ] diff --git a/coldfront/core/resource/migrations/0002_auto_20191017_1141.py b/coldfront/core/resource/migrations/0002_auto_20191017_1141.py index 2967786f55..d86ebd2255 100644 --- a/coldfront/core/resource/migrations/0002_auto_20191017_1141.py +++ b/coldfront/core/resource/migrations/0002_auto_20191017_1141.py @@ -1,23 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2019-10-17 15:41 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('resource', '0001_initial'), + ("resource", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='historicalresourceattribute', - name='value', + model_name="historicalresourceattribute", + name="value", field=models.TextField(), ), migrations.AlterField( - model_name='resourceattribute', - name='value', + model_name="resourceattribute", + name="value", field=models.TextField(), ), ] diff --git a/coldfront/core/resource/migrations/0003_alter_historicalresource_options_and_more.py b/coldfront/core/resource/migrations/0003_alter_historicalresource_options_and_more.py new file mode 100644 index 0000000000..511758497f --- /dev/null +++ b/coldfront/core/resource/migrations/0003_alter_historicalresource_options_and_more.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Generated by Django 4.2.23 on 2025-10-17 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("resource", "0002_auto_20191017_1141"), + ] + + operations = [ + migrations.AlterModelOptions( + name="historicalresource", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical resource", + "verbose_name_plural": "historical resources", + }, + ), + migrations.AlterModelOptions( + name="historicalresourceattribute", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical resource attribute", + "verbose_name_plural": "historical resource attributes", + }, + ), + migrations.AlterModelOptions( + name="historicalresourceattributetype", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical resource attribute type", + "verbose_name_plural": "historical resource attribute types", + }, + ), + migrations.AlterModelOptions( + name="historicalresourcetype", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical resource type", + "verbose_name_plural": "historical resource types", + }, + ), + migrations.AlterField( + model_name="historicalresource", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalresourceattribute", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalresourceattributetype", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalresourcetype", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="resource", + name="linked_resources", + field=models.ManyToManyField(blank=True, to="resource.resource"), + ), + ] diff --git a/coldfront/core/resource/migrations/__init__.py b/coldfront/core/resource/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/resource/migrations/__init__.py +++ b/coldfront/core/resource/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/resource/models.py b/coldfront/core/resource/models.py index dd1be130dd..d2a5531022 100644 --- a/coldfront/core/resource/models.py +++ b/coldfront/core/resource/models.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from datetime import datetime from django.contrib.auth.models import Group, User @@ -5,11 +9,13 @@ from django.db import models from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords + import coldfront.core.attribute_expansion as attribute_expansion + class AttributeType(TimeStampedModel): - """ An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. - + """An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. + Attributes: name (str): name of attribute data type """ @@ -20,17 +26,23 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class ResourceType(TimeStampedModel): - """ A resource type class links a resource and its value. - + """A resource type class links a resource and its value. + Attributes: name (str): name of resource type description (str): description of resource type """ + class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class ResourceTypeManager(models.Manager): def get_by_natural_key(self, name): @@ -43,23 +55,21 @@ def get_by_natural_key(self, name): @property def active_count(self): - """ + """ Returns: int: the number of active resources of that type """ - return ResourceAttribute.objects.filter( - resource__resource_type__name=self.name, value="Active").count() + return ResourceAttribute.objects.filter(resource__resource_type__name=self.name, value="Active").count() @property def inactive_count(self): - """ + """ Returns: int: the number of inactive resources of that type """ - return ResourceAttribute.objects.filter( - resource__resource_type__name=self.name, value="Inactive").count() + return ResourceAttribute.objects.filter(resource__resource_type__name=self.name, value="Inactive").count() def __str__(self): return self.name @@ -67,9 +77,10 @@ def __str__(self): def natural_key(self): return [self.name] + class ResourceAttributeType(TimeStampedModel): - """ A resource attribute type indicates the type of the attribute. Examples include slurm_specs and slurm_cluster. - + """A resource attribute type indicates the type of the attribute. Examples include slurm_specs and slurm_cluster. + Attributes: attribute_type (AttributeType): indicates the AttributeType of the attribute name (str): name of resource attribute type @@ -90,31 +101,36 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class Resource(TimeStampedModel): - """ A resource is something a center maintains and provides access to for the community. Examples include Budgetstorage, Server, and Software License. - + """A resource is something a center maintains and provides access to for the community. Examples include Budgetstorage, Server, and Software License. + Attributes: parent_resource (Resource): used for the Cluster Partition resource type as these partitions fall under a main cluster resource_type (ResourceType): the type of resource (Cluster, Storage, etc.) - name (str): name of resource - description (str): description of what the resource does and is used for + name (str): name of resource + description (str): description of what the resource does and is used for is_available (bool): indicates whether or not the resource is available for users to request an allocation for is_public (bool): indicates whether or not users can see the resource requires_payment (bool): indicates whether or not users have to pay to use this resource allowed_groups (Group): uses the Django Group model to allow certain user groups to request the resource allowed_users (User): links Django Users that are allowed to request the resource to the resource """ + class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class ResourceManager(models.Manager): def get_by_natural_key(self, name): return self.get(name=name) - parent_resource = models.ForeignKey( - 'self', on_delete=models.CASCADE, blank=True, null=True) + parent_resource = models.ForeignKey("self", on_delete=models.CASCADE, blank=True, null=True) resource_type = models.ForeignKey(ResourceType, on_delete=models.CASCADE) name = models.CharField(max_length=128, unique=True) description = models.TextField() @@ -124,7 +140,7 @@ def get_by_natural_key(self, name): requires_payment = models.BooleanField(default=False) allowed_groups = models.ManyToManyField(Group, blank=True) allowed_users = models.ManyToManyField(User, blank=True) - linked_resources = models.ManyToManyField('self', blank=True) + linked_resources = models.ManyToManyField("self", blank=True) history = HistoricalRecords() objects = ResourceManager() @@ -138,11 +154,9 @@ def get_missing_resource_attributes(self, required=False): """ if required: - resource_attributes = ResourceAttributeType.objects.filter( - resource_type=self.resource_type, required=True) + resource_attributes = ResourceAttributeType.objects.filter(resource_type=self.resource_type, required=True) else: - resource_attributes = ResourceAttributeType.objects.filter( - resource_type=self.resource_type) + resource_attributes = ResourceAttributeType.objects.filter(resource_type=self.resource_type) missing_resource_attributes = [] @@ -153,15 +167,14 @@ def get_missing_resource_attributes(self, required=False): @property def status(self): - """ + """ Returns: str: the status of the resource """ return ResourceAttribute.objects.get(resource=self, resource_attribute_type__attribute="Status").value - def get_attribute(self, name, expand=True, typed=True, - extra_allocations=[]): + def get_attribute(self, name, expand=True, typed=True, extra_allocations=[]): """ Params: name (str): name of the resource attribute type @@ -173,12 +186,10 @@ def get_attribute(self, name, expand=True, typed=True, str: the value of the first attribute found for this resource with the specified name """ - attr = self.resourceattribute_set.filter( - resource_attribute_type__name=name).first() + attr = self.resourceattribute_set.filter(resource_attribute_type__name=name).first() if attr: if expand: - return attr.expanded_value( - typed=typed, extra_allocations=extra_allocations) + return attr.expanded_value(typed=typed, extra_allocations=extra_allocations) else: if typed: return attr.typed_value() @@ -186,8 +197,7 @@ def get_attribute(self, name, expand=True, typed=True, return attr.value return None - def get_attribute_list(self, name, expand=True, typed=True, - extra_allocations=[]): + def get_attribute_list(self, name, expand=True, typed=True, extra_allocations=[]): """ Params: name (str): name of the resource @@ -199,11 +209,9 @@ def get_attribute_list(self, name, expand=True, typed=True, list: the list of values of the attributes found with specified name """ - attr = self.resourceattribute_set.filter( - resource_attribute_type__name=name).all() + attr = self.resourceattribute_set.filter(resource_attribute_type__name=name).all() if expand: - return [a.expanded_value(extra_allocations=extra_allocations, - typed=typed) for a in attr] + return [a.expanded_value(extra_allocations=extra_allocations, typed=typed) for a in attr] else: if typed: return [a.typed_value() for a in attr] @@ -216,55 +224,50 @@ def get_ondemand_status(self): str: If the resource has OnDemand status or not """ - ondemand = self.resourceattribute_set.filter( - resource_attribute_type__name='OnDemand').first() + ondemand = self.resourceattribute_set.filter(resource_attribute_type__name="OnDemand").first() if ondemand: return ondemand.value return None - + def __str__(self): - return '%s (%s)' % (self.name, self.resource_type.name) + return "%s (%s)" % (self.name, self.resource_type.name) def natural_key(self): return [self.name] + class ResourceAttribute(TimeStampedModel): - """ A resource attribute class links a resource attribute type and a resource. - + """A resource attribute class links a resource attribute type and a resource. + Attributes: resource_attribute_type (ResourceAttributeType): resource attribute type to link resource (Resource): resource to link value (str): value of the resource attribute """ - resource_attribute_type = models.ForeignKey( - ResourceAttributeType, on_delete=models.CASCADE) + resource_attribute_type = models.ForeignKey(ResourceAttributeType, on_delete=models.CASCADE) resource = models.ForeignKey(Resource, on_delete=models.CASCADE) value = models.TextField() history = HistoricalRecords() def clean(self): - """ Validates the resource and raises errors if the resource is invalid. """ + """Validates the resource and raises errors if the resource is invalid.""" expected_value_type = self.resource_attribute_type.attribute_type.name.strip() if expected_value_type == "Int" and not self.value.isdigit(): - raise ValidationError( - 'Invalid Value "%s". Value must be an integer.' % (self.value)) + raise ValidationError('Invalid Value "%s". Value must be an integer.' % (self.value)) elif expected_value_type == "Active/Inactive" and self.value not in ["Active", "Inactive"]: - raise ValidationError( - 'Invalid Value "%s". Allowed inputs are "Active" or "Inactive".' % (self.value)) + raise ValidationError('Invalid Value "%s". Allowed inputs are "Active" or "Inactive".' % (self.value)) elif expected_value_type == "Public/Private" and self.value not in ["Public", "Private"]: - raise ValidationError( - 'Invalid Value "%s". Allowed inputs are "Public" or "Private".' % (self.value)) + raise ValidationError('Invalid Value "%s". Allowed inputs are "Public" or "Private".' % (self.value)) elif expected_value_type == "Date": try: datetime.strptime(self.value.strip(), "%m/%d/%Y") except ValueError: - raise ValidationError( - 'Invalid Value "%s". Date must be in format MM/DD/YYYY' % (self.value)) + raise ValidationError('Invalid Value "%s". Date must be in format MM/DD/YYYY' % (self.value)) def __str__(self): - return '%s: %s (%s)' % (self.resource_attribute_type, self.value, self.resource) + return "%s: %s (%s)" % (self.resource_attribute_type, self.value, self.resource) def typed_value(self): """ @@ -274,8 +277,7 @@ def typed_value(self): raw_value = self.value atype_name = self.resource_attribute_type.attribute_type.name - return attribute_expansion.convert_type( - value=raw_value, type_name=atype_name) + return attribute_expansion.convert_type(value=raw_value, type_name=atype_name) def expanded_value(self, typed=True, extra_allocations=[]): """ @@ -286,41 +288,40 @@ def expanded_value(self, typed=True, extra_allocations=[]): Returns: int, float, str: the value of the attribute after attribute expansion - For attributes with attribute type of 'Attribute Expanded Text' we look for an attribute with same name suffixed with '_attriblist' (this should be ResourceAttribute of the Resource associated with the attribute). If the attriblist attribute is found, we use it to generate a dictionary to use to expand the attribute value, and the expanded value is returned. + For attributes with attribute type of 'Attribute Expanded Text' we look for an attribute with same name suffixed with '_attriblist' (this should be ResourceAttribute of the Resource associated with the attribute). If the attriblist attribute is found, we use it to generate a dictionary to use to expand the attribute value, and the expanded value is returned. If the expansion fails, or if no attriblist attribute is found, or if the attribute type is not 'Attribute Expanded Text', we just return the raw value. """ - + raw_value = self.value if typed: # Try to convert to python type as per AttributeType raw_value = self.typed_value() - if not attribute_expansion.is_expandable_type( - self.resource_attribute_type.attribute_type): + if not attribute_expansion.is_expandable_type(self.resource_attribute_type.attribute_type): # We are not an expandable type, return raw value return raw_value allocs = extra_allocations - resources = [ self.resource ] + resources = [self.resource] attrib_name = self.resource_attribute_type.name attriblist = attribute_expansion.get_attriblist_str( - attribute_name = attrib_name, - resources = resources, - allocations = allocs) + attribute_name=attrib_name, resources=resources, allocations=allocs + ) if not attriblist: # We do not have an attriblist, return raw value return raw_value expanded = attribute_expansion.expand_attribute( - raw_value = raw_value, - attribute_name = attrib_name, - attriblist_string = attriblist, - resources = resources, - allocations = allocs) + raw_value=raw_value, + attribute_name=attrib_name, + attriblist_string=attriblist, + resources=resources, + allocations=allocs, + ) return expanded class Meta: - unique_together = ('resource_attribute_type', 'resource') + unique_together = ("resource_attribute_type", "resource") diff --git a/coldfront/core/resource/templates/resource_detail.html b/coldfront/core/resource/templates/resource_detail.html index 8c99f9942d..681d1dcdf3 100644 --- a/coldfront/core/resource/templates/resource_detail.html +++ b/coldfront/core/resource/templates/resource_detail.html @@ -18,9 +18,9 @@

Resource Detail

-

Resource Information

+

Resource Information

- +
{% csrf_token %}
@@ -81,7 +81,7 @@

Resource Information

Resource Attributes

-
+
{% if request.user.is_superuser %} Add Resource Attribute @@ -126,7 +126,7 @@

Child
{% if child_resources %}
- +
@@ -160,18 +160,4 @@

Child - - {% endblock %} diff --git a/coldfront/core/resource/templates/resource_list.html b/coldfront/core/resource/templates/resource_list.html index e547ca22f2..ffb118bde0 100644 --- a/coldfront/core/resource/templates/resource_list.html +++ b/coldfront/core/resource/templates/resource_list.html @@ -17,12 +17,12 @@

Resources

-
+
{{ resource_search_form|crispy }} @@ -44,23 +44,23 @@

Resources

@@ -76,7 +76,7 @@

Resources

Resource Name
ID - Sort ID asc - Sort ID desc + Sort ID asc + Sort ID desc Resource Name - Sort Resource Name asc - Sort Resource Name desc + Sort Resource Name asc + Sort Resource Name desc Parent Resource - Sort Parent Resource asc - Sort Parent Resource desc + Sort Parent Resource asc + Sort Parent Resource desc Resource Type - Sort Resource Type asc - Sort Resource Type desc + Sort Resource Type asc + Sort Resource Type desc
{% if is_paginated %} Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} -
    +
      {% if page_obj.has_previous %}
    • Previous
    • {% else %} @@ -100,26 +100,4 @@

      Resources

{% endif %} - {% endblock %} diff --git a/coldfront/core/resource/templates/resource_resourceattribute_delete.html b/coldfront/core/resource/templates/resource_resourceattribute_delete.html index 7c6bc20691..d8775a6bf9 100644 --- a/coldfront/core/resource/templates/resource_resourceattribute_delete.html +++ b/coldfront/core/resource/templates/resource_resourceattribute_delete.html @@ -22,7 +22,7 @@

Delete allocation attributes from {{resource}}

- + Name Value @@ -58,16 +58,4 @@

Delete allocation attributes from {{resource}}

{% endif %} - {% endblock %} diff --git a/coldfront/core/resource/tests.py b/coldfront/core/resource/tests.py deleted file mode 100644 index 7ce503c2dd..0000000000 --- a/coldfront/core/resource/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/coldfront/core/resource/tests/__init__.py b/coldfront/core/resource/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/resource/tests/tests.py b/coldfront/core/resource/tests/tests.py new file mode 100644 index 0000000000..3f8679ae15 --- /dev/null +++ b/coldfront/core/resource/tests/tests.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Create your tests here. + +import logging +import sys + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from coldfront.core.test_helpers.factories import ( + RAttributeTypeFactory, + ResourceAttributeFactory, + ResourceAttributeTypeFactory, +) + +logging.disable(logging.CRITICAL) + + +class ResourceAttributeModelCleanMethodTests(TestCase): + def _test_clean( + self, resource_attribute_type_name: str, resource_attribute_values: list, expect_validation_error: bool + ): + attribute_type = RAttributeTypeFactory(name=resource_attribute_type_name) + resource_attribute_type = ResourceAttributeTypeFactory(attribute_type=attribute_type) + resource_attribute = ResourceAttributeFactory(resource_attribute_type=resource_attribute_type) + for value in resource_attribute_values: + with self.subTest(value=value): + if not isinstance(value, str): + raise TypeError("resource attribute value must be a string") + resource_attribute.value = value + if expect_validation_error: + with self.assertRaises(ValidationError): + resource_attribute.clean() + else: + resource_attribute.clean() + + def test_expect_int_given_int(self): + self._test_clean("Int", ["0", "1", str(sys.maxsize)], False) + + def test_expect_int_given_float(self): + # FIXME -1 should be a valid int + self._test_clean("Int", ["-1", "-1.0", "0.0", "1.0", "2e30"], True) + + def test_expect_int_given_garbage(self): + self._test_clean("Int", ["foobar", "", " ", "\0", "1j"], True) + + def test_expect_public_private_given_public_private(self): + self._test_clean("Public/Private", ["Public", "Private"], False) + + def test_expect_public_private_given_garbage(self): + self._test_clean( + "Public/Private", + ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j", "public", "private", "PUBLIC", "PRIVATE"], + True, + ) + + def test_expect_active_inactive_given_active_inactive(self): + self._test_clean("Active/Inactive", ["Active", "Inactive"], False) + + def test_expect_active_inactive_given_garbage(self): + self._test_clean( + "Active/Inactive", + ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j", "active", "inactive", "ACTIVE", "INACTIVE"], + True, + ) + + def test_expect_date_given_date(self): + # FIXME date format is different from project, allocation + self._test_clean("Date", ["01/01/1970"], False) + + def test_expect_date_given_garbage(self): + self._test_clean("Date", ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j"], True) diff --git a/coldfront/core/resource/urls.py b/coldfront/core/resource/urls.py index f8da148065..1dd0c3da2c 100644 --- a/coldfront/core/resource/urls.py +++ b/coldfront/core/resource/urls.py @@ -1,14 +1,22 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.resource.views as resource_views urlpatterns = [ - path('', resource_views.ResourceListView.as_view(), - name='resource-list'), - path('/', resource_views.ResourceDetailView.as_view(), - name='resource-detail'), - path('/resourceattribute/add', - resource_views.ResourceAttributeCreateView.as_view(), name='resource-attribute-add'), - path('/resourceattribute/delete', - resource_views.ResourceAttributeDeleteView.as_view(), name='resource-attribute-delete'), -] \ No newline at end of file + path("", resource_views.ResourceListView.as_view(), name="resource-list"), + path("/", resource_views.ResourceDetailView.as_view(), name="resource-detail"), + path( + "/resourceattribute/add", + resource_views.ResourceAttributeCreateView.as_view(), + name="resource-attribute-add", + ), + path( + "/resourceattribute/delete", + resource_views.ResourceAttributeDeleteView.as_view(), + name="resource-attribute-delete", + ), +] diff --git a/coldfront/core/resource/views.py b/coldfront/core/resource/views.py index 9b9884aaa9..be34e145c8 100644 --- a/coldfront/core/resource/views.py +++ b/coldfront/core/resource/views.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import forms from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin @@ -5,39 +9,62 @@ from django.db.models import Q from django.db.models.functions import Lower from django.forms import formset_factory -from django.http import HttpResponseRedirect, JsonResponse +from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.urls import reverse -from django.views.generic import TemplateView, ListView +from django.views.generic import ListView, TemplateView from django.views.generic.edit import CreateView -from coldfront.core.resource.forms import ResourceAttributeCreateForm, ResourceSearchForm, ResourceAttributeDeleteForm +from coldfront.core.resource.forms import ResourceAttributeCreateForm, ResourceAttributeDeleteForm, ResourceSearchForm from coldfront.core.resource.models import Resource, ResourceAttribute +class ResourceEULAView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): + model = Resource + template_name = "resource_eula.html" + context_object_name = "resource" + + def test_func(self): + """UserPassesTestMixin Tests""" + return True + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + pk = self.kwargs.get("pk") + resource_obj = get_object_or_404(Resource, pk=pk) + + attributes = [ + attribute + for attribute in resource_obj.resourceattribute_set.all().order_by("resource_attribute_type__name") + ] + + context["resource"] = resource_obj + context["attributes"] = attributes + + return context + + class ResourceDetailView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = Resource - template_name = 'resource_detail.html' - context_object_name = 'resource' + template_name = "resource_detail.html" + context_object_name = "resource" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" return True def get_child_resources(self, resource_obj): - child_resources = [resource for resource in resource_obj.resource_set.all( - ).order_by(Lower("name"))] + child_resources = [resource for resource in resource_obj.resource_set.all().order_by(Lower("name"))] child_resources = [ - - {'object': resource, - 'WarrantyExpirationDate': resource.get_attribute('WarrantyExpirationDate'), - 'ServiceEnd': resource.get_attribute('ServiceEnd'), - 'Vendor': resource.get_attribute('Vendor'), - 'SerialNumber': resource.get_attribute('SerialNumber'), - 'Model': resource.get_attribute('Model'), - } - + { + "object": resource, + "WarrantyExpirationDate": resource.get_attribute("WarrantyExpirationDate"), + "ServiceEnd": resource.get_attribute("ServiceEnd"), + "Vendor": resource.get_attribute("Vendor"), + "SerialNumber": resource.get_attribute("SerialNumber"), + "Model": resource.get_attribute("Model"), + } for resource in child_resources ] @@ -45,260 +72,248 @@ def get_child_resources(self, resource_obj): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - attributes = [attribute for attribute in resource_obj.resourceattribute_set.all( - ).order_by('resource_attribute_type__name')] + attributes = [ + attribute + for attribute in resource_obj.resourceattribute_set.all().order_by("resource_attribute_type__name") + ] child_resources = self.get_child_resources(resource_obj) - context['resource'] = resource_obj - context['attributes'] = attributes - context['child_resources'] = child_resources + context["resource"] = resource_obj + context["attributes"] = attributes + context["child_resources"] = child_resources return context + class ResourceAttributeCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = ResourceAttribute form_class = ResourceAttributeCreateForm # fields = '__all__' - template_name = 'resource_resourceattribute_create.html' + template_name = "resource_resourceattribute_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True else: - messages.error( - self.request, 'You do not have permission to add resource attributes.') + messages.error(self.request, "You do not have permission to add resource attributes.") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - context['resource'] = resource_obj + context["resource"] = resource_obj return context def get_initial(self): initial = super().get_initial() - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - initial['resource'] = resource_obj + initial["resource"] = resource_obj return initial def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" form = super().get_form(form_class) - form.fields['resource'].widget = forms.HiddenInput() + form.fields["resource"].widget = forms.HiddenInput() return form def get_success_url(self): - return reverse('resource-detail', kwargs={'pk': self.kwargs.get('pk')}) + return reverse("resource-detail", kwargs={"pk": self.kwargs.get("pk")}) class ResourceAttributeDeleteView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'resource_resourceattribute_delete.html' + template_name = "resource_resourceattribute_delete.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True else: - messages.error( - self.request, 'You do not have permission to delete resource attributes.') + messages.error(self.request, "You do not have permission to delete resource attributes.") def get_resource_attributes_to_delete(self, resource_obj): - - resource_attributes_to_delete = ResourceAttribute.objects.filter( - resource=resource_obj) + resource_attributes_to_delete = ResourceAttribute.objects.filter(resource=resource_obj) resource_attributes_to_delete = [ - - {'pk': attribute.pk, - 'name': attribute.resource_attribute_type.name, - 'value': attribute.value, - } - + { + "pk": attribute.pk, + "name": attribute.resource_attribute_type.name, + "value": attribute.value, + } for attribute in resource_attributes_to_delete ] return resource_attributes_to_delete def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - resource_attributes_to_delete = self.get_resource_attributes_to_delete( - resource_obj) + resource_attributes_to_delete = self.get_resource_attributes_to_delete(resource_obj) context = {} if resource_attributes_to_delete: - formset = formset_factory(ResourceAttributeDeleteForm, max_num=len( - resource_attributes_to_delete)) - formset = formset( - initial=resource_attributes_to_delete, prefix='attributeform') - context['formset'] = formset - context['resource'] = resource_obj + formset = formset_factory(ResourceAttributeDeleteForm, max_num=len(resource_attributes_to_delete)) + formset = formset(initial=resource_attributes_to_delete, prefix="attributeform") + context["formset"] = formset + context["resource"] = resource_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - resource_attributes_to_delete = self.get_resource_attributes_to_delete( - resource_obj) + resource_attributes_to_delete = self.get_resource_attributes_to_delete(resource_obj) - formset = formset_factory(ResourceAttributeDeleteForm, max_num=len( - resource_attributes_to_delete)) - formset = formset( - request.POST, initial=resource_attributes_to_delete, prefix='attributeform') + formset = formset_factory(ResourceAttributeDeleteForm, max_num=len(resource_attributes_to_delete)) + formset = formset(request.POST, initial=resource_attributes_to_delete, prefix="attributeform") attributes_deleted_count = 0 if formset.is_valid(): for form in formset: form_data = form.cleaned_data - if form_data['selected']: - + if form_data["selected"]: attributes_deleted_count += 1 - resource_attribute = ResourceAttribute.objects.get( - pk=form_data['pk']) + resource_attribute = ResourceAttribute.objects.get(pk=form_data["pk"]) resource_attribute.delete() - messages.success(request, 'Deleted {} attributes from resource.'.format( - attributes_deleted_count)) + messages.success(request, "Deleted {} attributes from resource.".format(attributes_deleted_count)) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('resource-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("resource-detail", kwargs={"pk": pk})) -class ResourceListView(LoginRequiredMixin, ListView): +class ResourceListView(LoginRequiredMixin, ListView): model = Resource - template_name = 'resource_list.html' - context_object_name = 'resource_list' + template_name = "resource_list.html" + context_object_name = "resource_list" paginate_by = 25 def get_queryset(self): - - order_by = self.request.GET.get('order_by', 'id') - direction = self.request.GET.get('direction', 'asc') + order_by = self.request.GET.get("order_by", "id") + direction = self.request.GET.get("direction", "asc") if order_by != "name": - if direction == 'asc': - direction = '' - if direction == 'des': - direction = '-' + if direction == "asc": + direction = "" + if direction == "des": + direction = "-" order_by = direction + order_by resource_search_form = ResourceSearchForm(self.request.GET) - + resources = Resource.objects.select_related( + "parent_resource", "parent_resource__resource_type", "resource_type" + ).all() if resource_search_form.is_valid(): data = resource_search_form.cleaned_data if order_by == "name": - direction = self.request.GET.get('direction') + direction = self.request.GET.get("direction") if direction == "asc": - resources = Resource.objects.all().order_by(Lower("name")) + resources = resources.order_by(Lower("name")) elif direction == "des": - resources = (Resource.objects.all().order_by(Lower("name")).reverse()) + resources = resources.order_by(Lower("name")).reverse() else: - resources = Resource.objects.all().order_by(order_by) + resources = resources.order_by(order_by) else: - resources = Resource.objects.all().order_by(order_by) + resources = resources.order_by(order_by) - if data.get('show_allocatable_resources'): + if data.get("show_allocatable_resources"): resources = resources.filter(is_allocatable=True) - if data.get('model'): + if data.get("model"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='Model') & - Q(resourceattribute__value=data.get('model')) + Q(resourceattribute__resource_attribute_type__name="Model") + & Q(resourceattribute__value=data.get("model")) ) - if data.get('serialNumber'): + if data.get("serialNumber"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='SerialNumber') & - Q(resourceattribute__value=data.get('serialNumber')) + Q(resourceattribute__resource_attribute_type__name="SerialNumber") + & Q(resourceattribute__value=data.get("serialNumber")) ) - if data.get('installDate'): + if data.get("installDate"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='InstallDate') & - Q(resourceattribute__value=data.get('installDate').strftime('%m/%d/%Y')) + Q(resourceattribute__resource_attribute_type__name="InstallDate") + & Q(resourceattribute__value=data.get("installDate").strftime("%m/%d/%Y")) ) - if data.get('serviceStart'): + if data.get("serviceStart"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type_name='ServiceStart') & - Q(resourceattribute__value=data.get('serviceStart').strftime('%m/%d/%Y')) + Q(resourceattribute__resource_attribute_type_name="ServiceStart") + & Q(resourceattribute__value=data.get("serviceStart").strftime("%m/%d/%Y")) ) - if data.get('serviceEnd'): + if data.get("serviceEnd"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='ServiceEnd') & - Q(resourceattribute__value=data.get('serviceEnd').strftime('%m/%d/%Y')) + Q(resourceattribute__resource_attribute_type__name="ServiceEnd") + & Q(resourceattribute__value=data.get("serviceEnd").strftime("%m/%d/%Y")) ) - if data.get('warrantyExpirationDate'): + if data.get("warrantyExpirationDate"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='WarrantyExpirationDate') & - Q(resourceattribute__value=data.get('warrantyExpirationDate').strftime('%m/%d/%Y')) + Q(resourceattribute__resource_attribute_type__name="WarrantyExpirationDate") + & Q(resourceattribute__value=data.get("warrantyExpirationDate").strftime("%m/%d/%Y")) ) - if data.get('vendor'): + if data.get("vendor"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='Vendor') & - Q(resourceattribute__value=data.get('vendor')) + Q(resourceattribute__resource_attribute_type__name="Vendor") + & Q(resourceattribute__value=data.get("vendor")) ) else: if order_by == "name": - direction = self.request.GET.get('direction') + direction = self.request.GET.get("direction") if direction == "asc": - resources = Resource.objects.all().order_by(Lower("name")) + resources = resources.order_by(Lower("name")) elif direction == "des": - resources = Resource.objects.all().order_by(Lower("name").reverse()) + resources = resources.order_by(Lower("name").reverse()) else: - resources = Resource.objects.all().order_by(order_by) + resources = resources.order_by(order_by) else: - resources = Resource.objects.all().order_by(order_by) + resources = resources.order_by(order_by) return resources.distinct() def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) resources_count = self.get_queryset().count() - context['resources_count'] = resources_count + context["resources_count"] = resources_count resource_search_form = ResourceSearchForm(self.request.GET) if resource_search_form.is_valid(): - context['resource_search_form'] = resource_search_form + context["resource_search_form"] = resource_search_form data = resource_search_form.cleaned_data - filter_parameters = '' + filter_parameters = "" for key, value in data.items(): if value: if isinstance(value, list): for ele in value: - filter_parameters += '{}={}&'.format(key, ele) + filter_parameters += "{}={}&".format(key, ele) else: - filter_parameters += '{}={}&'.format(key, value) - context['resource_search_form'] = resource_search_form + filter_parameters += "{}={}&".format(key, value) + context["resource_search_form"] = resource_search_form else: filter_parameters = None - context['resource_search_form'] = ResourceSearchForm() + context["resource_search_form"] = ResourceSearchForm() - order_by = self.request.GET.get('order_by') + order_by = self.request.GET.get("order_by") if order_by: - direction = self.request.GET.get('direction') - filter_parameters_with_order_by = filter_parameters + \ - 'order_by=%s&direction=%s&' % (order_by, direction) + direction = self.request.GET.get("direction") + filter_parameters_with_order_by = filter_parameters + "order_by=%s&direction=%s&" % (order_by, direction) else: filter_parameters_with_order_by = filter_parameters if filter_parameters: - context['expand_accordion'] = 'show' + context["expand_accordion"] = "show" - context['filter_parameters'] = filter_parameters - context['filter_parameters_with_order_by'] = filter_parameters_with_order_by + context["filter_parameters"] = filter_parameters + context["filter_parameters_with_order_by"] = filter_parameters_with_order_by - resource_list = context.get('resource_list') + resource_list = context.get("resource_list") paginator = Paginator(resource_list, self.paginate_by) - page = self.request.GET.get('page') + page = self.request.GET.get("page") try: resource_list = paginator.page(page) @@ -307,4 +322,3 @@ def get_context_data(self, **kwargs): except EmptyPage: resource_list = paginator.page(paginator.num_pages) return context - diff --git a/coldfront/core/test_helpers/decorators.py b/coldfront/core/test_helpers/decorators.py index 71b53d2353..41ec96259f 100644 --- a/coldfront/core/test_helpers/decorators.py +++ b/coldfront/core/test_helpers/decorators.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import functools import os import unittest @@ -7,9 +11,9 @@ def _skipUnlessEnvDefined(varname, reason=None): skip = varname not in os.environ if skip and reason is None: - reason = 'Automatically skipped. {} is not defined'.format(varname) + reason = "Automatically skipped. {} is not defined".format(varname) return functools.partial(unittest.skipIf, skip, reason) -makes_remote_requests = _skipUnlessEnvDefined('TESTS_ALLOW_REMOTE_REQUESTS') +makes_remote_requests = _skipUnlessEnvDefined("TESTS_ALLOW_REMOTE_REQUESTS") diff --git a/coldfront/core/test_helpers/factories.py b/coldfront/core/test_helpers/factories.py index 53298bd749..b0062aca5b 100644 --- a/coldfront/core/test_helpers/factories.py +++ b/coldfront/core/test_helpers/factories.py @@ -1,99 +1,119 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import factory from django.contrib.auth.models import User from factory import SubFactory -from factory.fuzzy import FuzzyChoice from factory.django import DjangoModelFactory +from factory.fuzzy import FuzzyChoice from faker import Faker from faker.providers import BaseProvider, DynamicProvider -from coldfront.core.field_of_science.models import FieldOfScience -from coldfront.core.resource.models import ResourceType, Resource -from coldfront.core.project.models import ( - Project, - ProjectUser, - ProjectAttribute, - ProjectAttributeType, - ProjectUserRoleChoice, - ProjectUserStatusChoice, - ProjectStatusChoice, - AttributeType as PAttributeType, -) from coldfront.core.allocation.models import ( Allocation, - AllocationUser, - AllocationUserNote, AllocationAttribute, - AllocationStatusChoice, + AllocationAttributeChangeRequest, AllocationAttributeType, + AllocationAttributeUsage, AllocationChangeRequest, AllocationChangeStatusChoice, - AllocationAttributeUsage, + AllocationStatusChoice, + AllocationUser, + AllocationUserNote, AllocationUserStatusChoice, - AllocationAttributeChangeRequest, +) +from coldfront.core.allocation.models import ( AttributeType as AAttributeType, ) +from coldfront.core.field_of_science.models import FieldOfScience from coldfront.core.grant.models import GrantFundingAgency, GrantStatusChoice +from coldfront.core.project.models import ( + AttributeType as PAttributeType, +) +from coldfront.core.project.models import ( + Project, + ProjectAttribute, + ProjectAttributeType, + ProjectStatusChoice, + ProjectUser, + ProjectUserRoleChoice, + ProjectUserStatusChoice, +) from coldfront.core.publication.models import PublicationSource - +from coldfront.core.resource.models import ( + AttributeType as RAttributeType, +) +from coldfront.core.resource.models import Resource, ResourceAttribute, ResourceAttributeType, ResourceType +from coldfront.core.user.models import UserProfile ### Default values and Faker provider setup ### -project_status_choice_names = ['New', 'Active', 'Archived'] -project_user_role_choice_names = ['User', 'Manager'] -field_of_science_names = ['Physics', 'Chemistry', 'Economics', 'Biology', 'Sociology'] -attr_types = ['Date', 'Int', 'Float', 'Text', 'Boolean'] +project_status_choice_names = ["New", "Active", "Archived"] +project_user_role_choice_names = ["User", "Manager"] +field_of_science_names = ["Physics", "Chemistry", "Economics", "Biology", "Sociology"] fake = Faker() + class ColdfrontProvider(BaseProvider): def project_title(self): - return f'{fake.last_name()}_lab'.lower() + return f"{fake.last_name()}_lab".lower() def resource_name(self): - return fake.word().lower()+ '/' + fake.word().lower() + return fake.word().lower() + "/" + fake.word().lower() def username(self): first_name = fake.first_name() last_name = fake.last_name() - return f'{first_name}{last_name}'.lower() + return f"{first_name}{last_name}".lower() -field_of_science_provider = DynamicProvider( - provider_name="fieldofscience", elements=field_of_science_names -) -attr_type_provider = DynamicProvider(provider_name="attr_types", elements=attr_types) -for provider in [ColdfrontProvider, field_of_science_provider, attr_type_provider]: - factory.Faker.add_provider(provider) +field_of_science_provider = DynamicProvider(provider_name="fieldofscience", elements=field_of_science_names) +for provider in [ColdfrontProvider, field_of_science_provider]: + factory.Faker.add_provider(provider) ### User factories ### + class UserFactory(DjangoModelFactory): class Meta: model = User - django_get_or_create = ('username',) - first_name = factory.Faker('first_name') - last_name = factory.Faker('last_name') + django_get_or_create = ("username",) + + first_name = factory.Faker("first_name") + last_name = factory.Faker("last_name") # username = factory.Faker('username') - username = factory.LazyAttribute(lambda o: f'{o.first_name}{o.last_name}') - email = factory.LazyAttribute(lambda o: '%s@example.com' % o.username) + username = factory.LazyAttribute(lambda o: f"{o.first_name}{o.last_name}") + email = factory.LazyAttribute(lambda o: "%s@example.com" % o.username) + + +class UserProfileFactory(DjangoModelFactory): + class Meta: + model = UserProfile + django_get_or_create = ("user",) + + is_pi = False + user = SubFactory(UserFactory) ### Field of Science factories ### + class FieldOfScienceFactory(DjangoModelFactory): class Meta: model = FieldOfScience - django_get_or_create = ('description',) + django_get_or_create = ("description",) # description = FuzzyChoice(field_of_science_names) - description = factory.Faker('fieldofscience') - + description = factory.Faker("fieldofscience") ### Grant factories ### + class GrantFundingAgencyFactory(DjangoModelFactory): class Meta: model = GrantFundingAgency @@ -104,15 +124,17 @@ class Meta: model = GrantStatusChoice - ### Project factories ### + class ProjectStatusChoiceFactory(DjangoModelFactory): """Factory for ProjectStatusChoice model""" + class Meta: model = ProjectStatusChoice # ensure that names are unique - django_get_or_create = ('name',) + django_get_or_create = ("name",) + # randomly generate names from list of default values name = FuzzyChoice(project_status_choice_names) @@ -120,11 +142,10 @@ class Meta: class ProjectFactory(DjangoModelFactory): class Meta: model = Project - django_get_or_create = ('title',) pi = SubFactory(UserFactory) - title = factory.Faker('project_title') - description = factory.Faker('sentence') + title = factory.Faker("project_title") + description = factory.Faker("sentence") field_of_science = SubFactory(FieldOfScienceFactory) status = SubFactory(ProjectStatusChoiceFactory) force_review = False @@ -134,21 +155,23 @@ class Meta: class ProjectUserRoleChoiceFactory(DjangoModelFactory): class Meta: model = ProjectUserRoleChoice - django_get_or_create = ('name',) - name = 'User' + django_get_or_create = ("name",) + + name = "User" class ProjectUserStatusChoiceFactory(DjangoModelFactory): class Meta: model = ProjectUserStatusChoice - django_get_or_create = ('name',) - name = 'Active' + django_get_or_create = ("name",) + + name = "Active" class ProjectUserFactory(DjangoModelFactory): class Meta: model = ProjectUser - django_get_or_create = ('project', 'user') + django_get_or_create = ("project", "user") project = SubFactory(ProjectFactory) user = SubFactory(UserFactory) @@ -156,102 +179,141 @@ class Meta: status = SubFactory(ProjectUserStatusChoiceFactory) - ### Project Attribute factories ### + class PAttributeTypeFactory(DjangoModelFactory): class Meta: model = PAttributeType # django_get_or_create = ('name',) - name = factory.Faker('attr_type') + + name = "Text" class ProjectAttributeTypeFactory(DjangoModelFactory): class Meta: model = ProjectAttributeType - name = 'Test attribute type' + + name = "Test attribute type" attribute_type = SubFactory(PAttributeTypeFactory) class ProjectAttributeFactory(DjangoModelFactory): class Meta: model = ProjectAttribute + proj_attr_type = SubFactory(ProjectAttributeTypeFactory) - value = 'Test attribute value' + value = "Test attribute value" project = SubFactory(ProjectFactory) - ### Publication factories ### + class PublicationSourceFactory(DjangoModelFactory): class Meta: model = PublicationSource - name = 'doi' - url = 'https://doi.org/' - + name = "doi" + url = "https://doi.org/" ### Resource factories ### + class ResourceTypeFactory(DjangoModelFactory): class Meta: model = ResourceType - django_get_or_create = ('name',) - name = 'Storage' + django_get_or_create = ("name",) + + name = "Storage" + class ResourceFactory(DjangoModelFactory): class Meta: model = Resource - django_get_or_create = ('name',) - name = factory.Faker('resource_name') + django_get_or_create = ("name",) - description = factory.Faker('sentence') + name = factory.Faker("resource_name") + + description = factory.Faker("sentence") resource_type = SubFactory(ResourceTypeFactory) +### Resource Attribute factories ### + + +class RAttributeTypeFactory(DjangoModelFactory): + class Meta: + model = RAttributeType + django_get_or_create = ("name",) + + name = "Text" + + +class ResourceAttributeTypeFactory(DjangoModelFactory): + class Meta: + model = ResourceAttributeType + django_get_or_create = ("name",) + + name = "Test attribute type" + attribute_type = SubFactory(RAttributeTypeFactory) + + +class ResourceAttributeFactory(DjangoModelFactory): + class Meta: + model = ResourceAttribute + + resource_attribute_type = SubFactory(ResourceAttributeTypeFactory) + value = "Test attribute value" + resource = SubFactory(ResourceFactory) + ### Allocation factories ### + class AllocationStatusChoiceFactory(DjangoModelFactory): class Meta: model = AllocationStatusChoice - django_get_or_create = ('name',) - name = 'Active' + django_get_or_create = ("name",) + + name = "Active" class AllocationFactory(DjangoModelFactory): class Meta: model = Allocation - django_get_or_create = ('project',) - justification = factory.Faker('sentence') + + justification = factory.Faker("sentence") status = SubFactory(AllocationStatusChoiceFactory) project = SubFactory(ProjectFactory) is_changeable = True - ### Allocation Attribute factories ### + class AAttributeTypeFactory(DjangoModelFactory): class Meta: model = AAttributeType - django_get_or_create = ('name',) - name='Int' + django_get_or_create = ("name",) + + name = "Int" class AllocationAttributeTypeFactory(DjangoModelFactory): class Meta: model = AllocationAttributeType - django_get_or_create = ('name',) - name = 'Test attribute type' + django_get_or_create = ("name",) + + name = "Test attribute type" attribute_type = SubFactory(AAttributeTypeFactory) class AllocationAttributeFactory(DjangoModelFactory): class Meta: model = AllocationAttribute + allocation_attribute_type = SubFactory(AllocationAttributeTypeFactory) value = 2048 allocation = SubFactory(AllocationFactory) @@ -260,19 +322,21 @@ class Meta: class AllocationAttributeUsageFactory(DjangoModelFactory): class Meta: model = AllocationAttributeUsage - django_get_or_create = ('allocation_attribute',) + django_get_or_create = ("allocation_attribute",) + allocation_attribute = SubFactory(AllocationAttributeFactory) value = 1024 - ### Allocation Change Request factories ### + class AllocationChangeStatusChoiceFactory(DjangoModelFactory): class Meta: model = AllocationChangeStatusChoice - django_get_or_create = ('name',) - name = 'Pending' + django_get_or_create = ("name",) + + name = "Pending" class AllocationChangeRequestFactory(DjangoModelFactory): @@ -281,7 +345,7 @@ class Meta: allocation = SubFactory(AllocationFactory) status = SubFactory(AllocationChangeStatusChoiceFactory) - justification = factory.Faker('sentence') + justification = factory.Faker("sentence") class AllocationAttributeChangeRequestFactory(DjangoModelFactory): @@ -293,20 +357,22 @@ class Meta: new_value = 1000 - ### Allocation User factories ### + class AllocationUserStatusChoiceFactory(DjangoModelFactory): class Meta: model = AllocationUserStatusChoice - django_get_or_create = ('name',) - name = 'Active' + django_get_or_create = ("name",) + + name = "Active" class AllocationUserFactory(DjangoModelFactory): class Meta: model = AllocationUser - django_get_or_create = ('allocation','user') + django_get_or_create = ("allocation", "user") + allocation = SubFactory(AllocationFactory) user = SubFactory(UserFactory) status = SubFactory(AllocationUserStatusChoiceFactory) @@ -315,7 +381,8 @@ class Meta: class AllocationUserNoteFactory(DjangoModelFactory): class Meta: model = AllocationUserNote - django_get_or_create = ('allocation') + django_get_or_create = "allocation" + allocation = SubFactory(AllocationFactory) author = SubFactory(AllocationUserFactory) - note = factory.Faker('sentence') + note = factory.Faker("sentence") diff --git a/coldfront/core/test_helpers/utils.py b/coldfront/core/test_helpers/utils.py index f03c25f126..9924295cbc 100644 --- a/coldfront/core/test_helpers/utils.py +++ b/coldfront/core/test_helpers/utils.py @@ -1,20 +1,32 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.contrib import messages +from django.test import TestCase +from requests import Response + """utility functions for unit and integration testing""" + def login_and_get_page(client, user, page): """force login and return get response for page""" client.force_login(user, backend="django.contrib.auth.backends.ModelBackend") return client.get(page) + def page_contains_for_user(test_case, user, url, text): """Check that page contains text for user""" response = login_and_get_page(test_case.client, user, url) test_case.assertContains(response, text) + def page_does_not_contain_for_user(test_case, user, url, text): """Check that page contains text for user""" response = login_and_get_page(test_case.client, user, url) test_case.assertNotContains(response, text) + def test_logged_out_redirect_to_login(test_case, page): """ Confirm that attempting to access page while not logged in triggers a 302 @@ -29,7 +41,8 @@ def test_logged_out_redirect_to_login(test_case, page): # log out, in case already logged in test_case.client.logout() response = test_case.client.get(page) - test_case.assertRedirects(response, f'/user/login?next={page}') + test_case.assertRedirects(response, f"/user/login?next={page}") + def test_redirect(test_case, page): """ @@ -51,6 +64,7 @@ def test_redirect(test_case, page): test_case.assertEqual(response.status_code, 302) return response.url + def test_user_cannot_access(test_case, user, page): """Confirm that accessing the page as the designated user returns a 403 response code. @@ -65,6 +79,7 @@ def test_user_cannot_access(test_case, user, page): response = login_and_get_page(test_case.client, user, page) test_case.assertEqual(response.status_code, 403) + def test_user_can_access(test_case, user, page): """Confirm that accessing the page as the designated user returns a 200 response code. @@ -78,3 +93,13 @@ def test_user_can_access(test_case, user, page): """ response = login_and_get_page(test_case.client, user, page) test_case.assertEqual(response.status_code, 200) + + +def assert_response_success(test_case: TestCase, response: Response): + """Confirm that response status is 200 and response contains no error messages""" + test_case.assertEqual(response.status_code, 200) + errors = [] + for message in response.context["messages"]: + if message.level >= messages.ERROR: + errors.append(message.message) + test_case.assertEqual([], errors) diff --git a/coldfront/core/user/__init__.py b/coldfront/core/user/__init__.py index cbe968a216..6d24412f63 100644 --- a/coldfront/core/user/__init__.py +++ b/coldfront/core/user/__init__.py @@ -1 +1,4 @@ -default_app_config = 'coldfront.core.user.apps.UserConfig' +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + diff --git a/coldfront/core/user/admin.py b/coldfront/core/user/admin.py index 40755b6272..8cb672b964 100644 --- a/coldfront/core/user/admin.py +++ b/coldfront/core/user/admin.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from coldfront.core.user.models import UserProfile @@ -5,9 +9,14 @@ @admin.register(UserProfile) class UserProfileAdmin(admin.ModelAdmin): - list_display = ('username', 'first_name', 'last_name', 'is_pi',) - list_filter = ('is_pi',) - search_fields = ['user__username', 'user__first_name', 'user__last_name'] + list_display = ( + "username", + "first_name", + "last_name", + "is_pi", + ) + list_filter = ("is_pi",) + search_fields = ["user__username", "user__first_name", "user__last_name"] def username(self, obj): return obj.user.username diff --git a/coldfront/core/user/apps.py b/coldfront/core/user/apps.py index a024fa2db6..fbb15f8781 100644 --- a/coldfront/core/user/apps.py +++ b/coldfront/core/user/apps.py @@ -1,8 +1,14 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import importlib + from django.apps import AppConfig class UserConfig(AppConfig): - name = 'coldfront.core.user' + name = "coldfront.core.user" def ready(self): - import coldfront.core.user.signals + importlib.import_module("coldfront.core.user.signals") diff --git a/coldfront/core/user/forms.py b/coldfront/core/user/forms.py index 39bdbbe360..f1ed8f07d4 100644 --- a/coldfront/core/user/forms.py +++ b/coldfront/core/user/forms.py @@ -1,12 +1,33 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import forms +from django.forms.widgets import CheckboxSelectMultiple from django.utils.html import mark_safe class UserSearchForm(forms.Form): - CHOICES = [('username_only', 'Exact Username Only'), - # ('all_fields', mark_safe('All Fields ')), - ('all_fields', mark_safe('All Fields This option will be ignored if multiple usernames are entered in the search user text area.')), - ] - q = forms.CharField(label='Search String', min_length=2, widget=forms.Textarea(attrs={'rows': 4}), - help_text='Copy paste usernames separated by space or newline for multiple username searches!') - search_by = forms.ChoiceField(choices=CHOICES, widget=forms.RadioSelect(), initial='username_only') + CHOICES = [ + ("username_only", "Exact Username Only"), + ( + "all_fields", + mark_safe( + 'All Fields This option will be ignored if multiple usernames are entered in the search user text area.' + ), + ), + ] + q = forms.CharField( + label="Search String", + min_length=2, + widget=forms.Textarea(attrs={"rows": 4}), + help_text="Copy paste usernames separated by space or newline for multiple username searches!", + ) + search_by = forms.ChoiceField(choices=CHOICES, widget=forms.RadioSelect(), initial="username_only") + + +class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField): + widget = CheckboxSelectMultiple + + def label_from_instance(self, obj): + return f"{obj.first_name} {obj.last_name} ({obj.username})" diff --git a/coldfront/core/user/migrations/0001_initial.py b/coldfront/core/user/migrations/0001_initial.py index 1ebe6c61a3..7cfe198ec9 100644 --- a/coldfront/core/user/migrations/0001_initial.py +++ b/coldfront/core/user/migrations/0001_initial.py @@ -1,12 +1,15 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - initial = True dependencies = [ @@ -15,11 +18,14 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='UserProfile', + name="UserProfile", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_pi', models.BooleanField(default=False)), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("is_pi", models.BooleanField(default=False)), + ( + "user", + models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), ], ), ] diff --git a/coldfront/core/user/migrations/__init__.py b/coldfront/core/user/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/user/migrations/__init__.py +++ b/coldfront/core/user/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/user/models.py b/coldfront/core/user/models.py index ca1b0a07e1..e5264730a5 100644 --- a/coldfront/core/user/models.py +++ b/coldfront/core/user/models.py @@ -1,14 +1,18 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib.auth.models import User from django.db import models class UserProfile(models.Model): - """ Displays a user's profile. A user can be a principal investigator (PI), manager, administrator, staff member, billing staff member, or center director. + """Displays a user's profile. A user can be a principal investigator (PI), manager, administrator, staff member, billing staff member, or center director. Attributes: is_pi (bool): indicates whether or not the user is a PI - user (User): represents the Django User model + user (User): represents the Django User model """ user = models.OneToOneField(User, on_delete=models.CASCADE) - is_pi = models.BooleanField(default=False) \ No newline at end of file + is_pi = models.BooleanField(default=False) diff --git a/coldfront/core/user/signals.py b/coldfront/core/user/signals.py index 9f8d881dd8..87e18e1fca 100644 --- a/coldfront/core/user/signals.py +++ b/coldfront/core/user/signals.py @@ -1,5 +1,8 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib.auth.models import User -from django.contrib.auth.signals import user_logged_in from django.db.models.signals import post_save from django.dispatch import receiver diff --git a/coldfront/core/user/templates/user/login.html b/coldfront/core/user/templates/user/login.html index 08e0af507d..897c16ec43 100644 --- a/coldfront/core/user/templates/user/login.html +++ b/coldfront/core/user/templates/user/login.html @@ -13,8 +13,8 @@ {% if form.errors %} {% endif %} @@ -24,21 +24,19 @@

Log In

- {% include "user/login_form.html" %} + {% include "user/login_form.html" %} {% if 'mozilla_django_oidc' in EXTRA_APPS %}

OR

- - Log in via OpenID Connect - + {% endif %}
- {% endblock %} diff --git a/coldfront/core/user/templates/user/login_form.html b/coldfront/core/user/templates/user/login_form.html index a5a6e5c385..0d82f1c444 100644 --- a/coldfront/core/user/templates/user/login_form.html +++ b/coldfront/core/user/templates/user/login_form.html @@ -1,9 +1,11 @@ -{% load common_tags %} +{% load common_tags %} {% load crispy_forms_tags %} {% csrf_token %} {{ form|crispy }} - - \ No newline at end of file +
+ +
+ diff --git a/coldfront/core/user/templates/user/user_list_allocations.html b/coldfront/core/user/templates/user/user_list_allocations.html index 6856c78584..36461b6e41 100644 --- a/coldfront/core/user/templates/user/user_list_allocations.html +++ b/coldfront/core/user/templates/user/user_list_allocations.html @@ -7,7 +7,7 @@
- +
@@ -27,7 +27,7 @@ {% endfor %} @@ -36,28 +36,10 @@ - {% else %}
You are not a PI on any project with an allocation!
{% endif %} - {% endblock %} - diff --git a/coldfront/core/user/templates/user/user_profile.html b/coldfront/core/user/templates/user/user_profile.html index 18a7b0899a..efb36685c6 100644 --- a/coldfront/core/user/templates/user/user_profile.html +++ b/coldfront/core/user/templates/user/user_profile.html @@ -13,7 +13,7 @@

User Profile

{{ viewed_user.username }} - @@ -24,47 +24,44 @@

User Profile

# {% for allocation in allocations %} {{ allocation.get_parent_resource.name }} ({{ allocation.get_parent_resource.resource_type.name }}) {% if 'slurm' in allocation.get_information %} -- {{allocation.get_information}} {% else %}
{% endif %} - {% endfor %} + {% endfor %}
- - - - - - - - - - - - - - - - + {% block profile_contents %} + + + + + + + + + + + + + + + + + {% endblock %}
University Role(s):{{group_list}}
Email:{{viewed_user.email}}
PI Status: - {% if viewed_user.userprofile.is_pi %} - Yes - {% elif not user == viewed_user %} - No - {% else %} -
-
- No -
-
- {% csrf_token %} - -
-
- {% endif %} -
Last Login:{{viewed_user.last_login}}
University Role(s):{{group_list}}
Email:{{viewed_user.email}}
PI Status: + {% if viewed_user.userprofile.is_pi %} + Yes + {% elif not user == viewed_user %} + No + {% else %} +
+
+ No +
+
+ {% csrf_token %} + +
+
+ {% endif %} +
Last Login:{{viewed_user.last_login}}
- {% endblock %} diff --git a/coldfront/core/user/templates/user/user_projects_managers.html b/coldfront/core/user/templates/user/user_projects_managers.html index 1f3bc39e6f..fb161bdc54 100644 --- a/coldfront/core/user/templates/user/user_projects_managers.html +++ b/coldfront/core/user/templates/user/user_projects_managers.html @@ -21,27 +21,27 @@

User Projects and Managers{% if not user == viewed_user %}: {{ viewed_user.u

{{ project.title }}

{% if user == viewed_user or perms.project.can_view_all_projects %} - Details + Details {% endif %}
{# support non-default roles #} {% if not association.is_project_manager and not association.role.name == 'User' %} -

User role in project: {{ association.role }}

+

User role in project: {{ association.role }}

{% endif %} {# a few Pending statuses may be displayed here #} -

User status in project: +

User status in project: {% if association.status.name == 'Active' %} {{ association.status.name }} {% else %} {{ association.status.name }} {% endif %}

-

Description: {{ project.description }}

-

Field of Science: {{ project.field_of_science }}

-

Project Status: {{ project.status }}

+

Description: {{ project.description }}

+

Field of Science: {{ project.field_of_science }}

+

Project Status: {{ project.status }}

Principal Investigator: {{ project.pi.first_name }} {{ project.pi.last_name }} ({{ project.pi.username }}) - Email + Email {% if association.is_project_pi %} ({{ user_pronounish | lower }} {{user_verbform_be}} the project PI) {% endif %} @@ -51,9 +51,9 @@

{{ project.title }}

-

Additional Managers

{{ project.project_managers | length }} +

Additional Managers

{{ project.project_managers | length }} {% if association.is_project_manager and not association.is_project_pi %} -
+

{{ user_pronounish }} {{user_verbform_be}} a manager of this project.

{% endif %} diff --git a/coldfront/core/user/templates/user/user_search_home.html b/coldfront/core/user/templates/user/user_search_home.html index 4839685938..d6edc6a0f8 100644 --- a/coldfront/core/user/templates/user/user_search_home.html +++ b/coldfront/core/user/templates/user/user_search_home.html @@ -9,8 +9,10 @@
{% csrf_token %} {{ user_search_form|crispy }} - +
+ +
@@ -27,10 +29,7 @@ {% block javascript %} {{ block.super }} {% endblock %} diff --git a/coldfront/core/user/tests/__init__.py b/coldfront/core/user/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/user/tests.py b/coldfront/core/user/tests/tests.py similarity index 79% rename from coldfront/core/user/tests.py rename to coldfront/core/user/tests/tests.py index c0c42bdc22..ccedfbefa8 100644 --- a/coldfront/core/user/tests.py +++ b/coldfront/core/user/tests/tests.py @@ -1,27 +1,24 @@ -from coldfront.core.test_helpers.factories import UserFactory -from django.test import TestCase +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later -from coldfront.core.test_helpers.factories import ( - UserFactory, -) +from django.test import TestCase +from coldfront.core.test_helpers.factories import UserFactory from coldfront.core.user.models import UserProfile + class TestUserProfile(TestCase): class Data: """Collection of test data, separated for readability""" def __init__(self): - user = UserFactory(username='submitter') - - self.initial_fields = { - 'user': user, - 'is_pi': True, - 'id': user.id - } - + user = UserFactory(username="submitter") + + self.initial_fields = {"user": user, "is_pi": True, "id": user.id} + self.unsaved_object = UserProfile(**self.initial_fields) - + def setUp(self): self.data = self.Data() @@ -51,4 +48,4 @@ def test_user_on_delete(self): # expecting CASCADE with self.assertRaises(UserProfile.DoesNotExist): UserProfile.objects.get(pk=profile_obj.pk) - self.assertEqual(0, len(UserProfile.objects.all())) \ No newline at end of file + self.assertEqual(0, len(UserProfile.objects.all())) diff --git a/coldfront/core/user/urls.py b/coldfront/core/user/urls.py index b5b3fd0cf2..88d73c0868 100644 --- a/coldfront/core/user/urls.py +++ b/coldfront/core/user/urls.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.conf import settings -from django.contrib.auth.views import LoginView, LogoutView -from django.urls import path, reverse_lazy +from django.contrib.auth.views import LoginView +from django.urls import path import coldfront.core.user.views as user_views @@ -8,20 +12,24 @@ urlpatterns = [ - path('login', - LoginView.as_view( - template_name='user/login.html', - extra_context={'EXTRA_APPS': EXTRA_APPS}, - redirect_authenticated_user=True), - name='login' - ), - path('logout', LogoutView.as_view(), name='logout'), - path('user-profile/', user_views.UserProfile.as_view(), name='user-profile'), - path('user-profile/', user_views.UserProfile.as_view(), name='user-profile'), - path('user-projects-managers/', user_views.UserProjectsManagersView.as_view(), name='user-projects-managers'), - path('user-projects-managers/', user_views.UserProjectsManagersView.as_view(), name='user-projects-managers'), - path('user-upgrade/', user_views.UserUpgradeAccount.as_view(), name='user-upgrade'), - path('user-search-home/', user_views.UserSearchHome.as_view(), name='user-search-home'), - path('user-search-results/', user_views.UserSearchResults.as_view(), name='user-search-results'), - path('user-list-allocations/', user_views.UserListAllocations.as_view(), name='user-list-allocations'), + path( + "login", + LoginView.as_view( + template_name="user/login.html", extra_context={"EXTRA_APPS": EXTRA_APPS}, redirect_authenticated_user=True + ), + name="login", + ), + path("logout", user_views.HtmxLogoutView.as_view(), name="logout"), + path("user-profile/", user_views.UserProfile.as_view(), name="user-profile"), + path("user-profile/", user_views.UserProfile.as_view(), name="user-profile"), + path("user-projects-managers/", user_views.UserProjectsManagersView.as_view(), name="user-projects-managers"), + path( + "user-projects-managers/", + user_views.UserProjectsManagersView.as_view(), + name="user-projects-managers", + ), + path("user-upgrade/", user_views.UserUpgradeAccount.as_view(), name="user-upgrade"), + path("user-search-home/", user_views.UserSearchHome.as_view(), name="user-search-home"), + path("user-search-results/", user_views.UserSearchResults.as_view(), name="user-search-results"), + path("user-list-allocations/", user_views.UserListAllocations.as_view(), name="user-list-allocations"), ] diff --git a/coldfront/core/user/utils.py b/coldfront/core/user/utils.py index 8bd31290b7..0b9da4a235 100644 --- a/coldfront/core/user/utils.py +++ b/coldfront/core/user/utils.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import abc import logging @@ -9,22 +13,20 @@ logger = logging.getLogger(__name__) -class UserSearch(abc.ABC): +class UserSearch(abc.ABC): def __init__(self, user_search_string, search_by): self.user_search_string = user_search_string self.search_by = search_by @abc.abstractmethod - def search_a_user(self, user_search_string=None, search_by='all_fields'): + def search_a_user(self, user_search_string=None, search_by="all_fields"): pass def search(self): if len(self.user_search_string.split()) > 1: - search_by = 'username_only' + search_by = "username_only" matches = [] - number_of_usernames_found = 0 - users_not_found = [] user_search_string = sorted(list(set(self.user_search_string.split()))) for username in user_search_string: @@ -38,19 +40,23 @@ def search(self): class LocalUserSearch(UserSearch): - search_source = 'local' + search_source = "local" - def search_a_user(self, user_search_string=None, search_by='all_fields'): + def search_a_user(self, user_search_string=None, search_by="all_fields"): size_limit = 50 - if user_search_string and search_by == 'all_fields': - entries = User.objects.filter( - Q(username__icontains=user_search_string) | - Q(first_name__icontains=user_search_string) | - Q(last_name__icontains=user_search_string) | - Q(email__icontains=user_search_string) - ).filter(Q(is_active=True)).distinct()[:size_limit] - - elif user_search_string and search_by == 'username_only': + if user_search_string and search_by == "all_fields": + entries = ( + User.objects.filter( + Q(username__icontains=user_search_string) + | Q(first_name__icontains=user_search_string) + | Q(last_name__icontains=user_search_string) + | Q(email__icontains=user_search_string) + ) + .filter(Q(is_active=True)) + .distinct()[:size_limit] + ) + + elif user_search_string and search_by == "username_only": entries = User.objects.filter(username=user_search_string, is_active=True) else: entries = User.objects.all()[:size_limit] @@ -59,11 +65,11 @@ def search_a_user(self, user_search_string=None, search_by='all_fields'): for idx, user in enumerate(entries, 1): if user: user_dict = { - 'last_name': user.last_name, - 'first_name': user.first_name, - 'username': user.username, - 'email': user.email, - 'source': self.search_source, + "last_name": user.last_name, + "first_name": user.first_name, + "username": user.username, + "email": user.email, + "source": self.search_source, } users.append(user_dict) @@ -72,28 +78,25 @@ def search_a_user(self, user_search_string=None, search_by='all_fields'): class CombinedUserSearch: - def __init__(self, user_search_string, search_by, usernames_names_to_exclude=[]): - self.USER_SEARCH_CLASSES = import_from_settings('ADDITIONAL_USER_SEARCH_CLASSES', []) - self.USER_SEARCH_CLASSES.insert(0, 'coldfront.core.user.utils.LocalUserSearch') + self.USER_SEARCH_CLASSES = import_from_settings("ADDITIONAL_USER_SEARCH_CLASSES", []) + self.USER_SEARCH_CLASSES.insert(0, "coldfront.core.user.utils.LocalUserSearch") self.user_search_string = user_search_string self.search_by = search_by self.usernames_names_to_exclude = usernames_names_to_exclude def search(self): - matches = [] usernames_not_found = [] usernames_found = [] - for search_class in self.USER_SEARCH_CLASSES: cls = import_string(search_class) search_class_obj = cls(self.user_search_string, self.search_by) users = search_class_obj.search() for user in users: - username = user.get('username') + username = user.get("username") if username not in usernames_found and username not in self.usernames_names_to_exclude: usernames_found.append(username) matches.append(user) @@ -101,16 +104,18 @@ def search(self): if len(self.user_search_string.split()) > 1: number_of_usernames_searched = len(self.user_search_string.split()) number_of_usernames_found = len(usernames_found) - usernames_not_found = list(set(self.user_search_string.split()) - set(usernames_found) - set(self.usernames_names_to_exclude)) + usernames_not_found = list( + set(self.user_search_string.split()) - set(usernames_found) - set(self.usernames_names_to_exclude) + ) else: number_of_usernames_searched = None number_of_usernames_found = None usernames_not_found = None context = { - 'matches': matches, - 'number_of_usernames_searched': number_of_usernames_searched, - 'number_of_usernames_found': number_of_usernames_found, - 'usernames_not_found': usernames_not_found + "matches": matches, + "number_of_usernames_searched": number_of_usernames_searched, + "number_of_usernames_found": number_of_usernames_found, + "usernames_not_found": usernames_not_found, } return context diff --git a/coldfront/core/user/views.py b/coldfront/core/user/views.py index b68ac0e8ee..4207b25f62 100644 --- a/coldfront/core/user/views.py +++ b/coldfront/core/user/views.py @@ -1,13 +1,19 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging from django.contrib import messages +from django.contrib.auth import logout as auth_logout from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.models import User +from django.contrib.auth.views import LogoutView from django.db.models import BooleanField, Prefetch from django.db.models.expressions import ExpressionWrapper, F, Q from django.db.models.functions import Lower -from django.http import HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils.decorators import method_decorator @@ -21,15 +27,12 @@ from coldfront.core.utils.mail import send_email_template logger = logging.getLogger(__name__) -EMAIL_ENABLED = import_from_settings('EMAIL_ENABLED', False) -if EMAIL_ENABLED: - EMAIL_TICKET_SYSTEM_ADDRESS = import_from_settings( - 'EMAIL_TICKET_SYSTEM_ADDRESS') +EMAIL_TICKET_SYSTEM_ADDRESS = import_from_settings("EMAIL_TICKET_SYSTEM_ADDRESS") -@method_decorator(login_required, name='dispatch') +@method_decorator(login_required, name="dispatch") class UserProfile(TemplateView): - template_name = 'user/user_profile.html' + template_name = "user/user_profile.html" def dispatch(self, request, *args, viewed_username=None, **kwargs): # viewing another user profile requires permissions @@ -45,7 +48,7 @@ def dispatch(self, request, *args, viewed_username=None, **kwargs): messages.error(request, "You aren't allowed to view other user profiles!") # if they used their own username, no need to provide an error - just redirect - return HttpResponseRedirect(reverse('user-profile')) + return HttpResponseRedirect(reverse("user-profile")) return super().dispatch(request, *args, viewed_username=viewed_username, **kwargs) @@ -57,16 +60,15 @@ def get_context_data(self, viewed_username=None, **kwargs): else: viewed_user = self.request.user - group_list = ', '.join( - [group.name for group in viewed_user.groups.all()]) - context['group_list'] = group_list - context['viewed_user'] = viewed_user + group_list = ", ".join([group.name for group in viewed_user.groups.all()]) + context["group_list"] = group_list + context["viewed_user"] = viewed_user return context -@method_decorator(login_required, name='dispatch') +@method_decorator(login_required, name="dispatch") class UserProjectsManagersView(ListView): - template_name = 'user/user_projects_managers.html' + template_name = "user/user_projects_managers.html" def dispatch(self, request, *args, viewed_username=None, **kwargs): # viewing another user requires permissions @@ -82,7 +84,7 @@ def dispatch(self, request, *args, viewed_username=None, **kwargs): messages.error(request, "You aren't allowed to view projects for other users!") # if they used their own username, no need to provide an error - just redirect - return HttpResponseRedirect(reverse('user-projects-managers')) + return HttpResponseRedirect(reverse("user-projects-managers")) # get_queryset does not get kwargs, so we need to store it off here if viewed_username: @@ -96,77 +98,90 @@ def get_queryset(self, *args, **kwargs): viewed_user = self.viewed_user ongoing_projectuser_statuses = ( - 'Active', - 'Pending - Add', - 'Pending - Remove', + "Active", + "Pending - Add", + "Pending - Remove", ) ongoing_project_statuses = ( - 'New', - 'Active', + "New", + "Active", ) - qs = ProjectUser.objects.filter( - user=viewed_user, - status__name__in=ongoing_projectuser_statuses, - project__status__name__in=ongoing_project_statuses, - ).select_related( - 'status', - 'role', - 'project', - 'project__status', - 'project__field_of_science', - 'project__pi', - ).only( - 'status__name', - 'role__name', - 'project__title', - 'project__status__name', - 'project__field_of_science__description', - 'project__pi__username', - 'project__pi__first_name', - 'project__pi__last_name', - 'project__pi__email', - ).annotate( - is_project_pi=ExpressionWrapper( - Q(user=F('project__pi')), - output_field=BooleanField(), - ), - is_project_manager=ExpressionWrapper( - Q(role__name='Manager'), - output_field=BooleanField(), - ), - ).order_by( - '-is_project_pi', - '-is_project_manager', - Lower('project__pi__username').asc(), - Lower('project__title').asc(), - # unlikely things will get to this point unless there's almost-duplicate projects - '-project__pk', # more performant stand-in for '-project__created' - ).prefetch_related( - Prefetch( - lookup='project__projectuser_set', - queryset=ProjectUser.objects.filter( - role__name='Manager', - status__name__in=ongoing_projectuser_statuses, - ).exclude( - user__pk__in=[ - F('project__pi__pk'), # we assume pi is 'Manager' or can act like one - no need to list twice - viewed_user.pk, # we display elsewhere if the user is a manager of this project - ], - ).select_related( - 'status', - 'user', - ).only( - 'status__name', - 'user__username', - 'user__first_name', - 'user__last_name', - 'user__email', - ).order_by( - 'user__username', + qs = ( + ProjectUser.objects.filter( + user=viewed_user, + status__name__in=ongoing_projectuser_statuses, + project__status__name__in=ongoing_project_statuses, + ) + .select_related( + "status", + "role", + "project", + "project__status", + "project__field_of_science", + "project__pi", + ) + .only( + "status__name", + "role__name", + "project__title", + "project__status__name", + "project__field_of_science__description", + "project__pi__username", + "project__pi__first_name", + "project__pi__last_name", + "project__pi__email", + ) + .annotate( + is_project_pi=ExpressionWrapper( + Q(user=F("project__pi")), + output_field=BooleanField(), + ), + is_project_manager=ExpressionWrapper( + Q(role__name="Manager"), + output_field=BooleanField(), + ), + ) + .order_by( + "-is_project_pi", + "-is_project_manager", + Lower("project__pi__username").asc(), + Lower("project__title").asc(), + # unlikely things will get to this point unless there's almost-duplicate projects + "-project__pk", # more performant stand-in for '-project__created' + ) + .prefetch_related( + Prefetch( + lookup="project__projectuser_set", + queryset=ProjectUser.objects.filter( + role__name="Manager", + status__name__in=ongoing_projectuser_statuses, + ) + .exclude( + user__pk__in=[ + F( + "project__pi__pk" + ), # we assume pi is 'Manager' or can act like one - no need to list twice + viewed_user.pk, # we display elsewhere if the user is a manager of this project + ], + ) + .select_related( + "status", + "user", + ) + .only( + "status__name", + "user__username", + "user__first_name", + "user__last_name", + "user__email", + ) + .order_by( + "user__username", + ), + to_attr="project_managers", ), - to_attr='project_managers', - ), + ) ) return qs @@ -174,54 +189,51 @@ def get_queryset(self, *args, **kwargs): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['viewed_user'] = self.viewed_user + context["viewed_user"] = self.viewed_user if self.request.user == self.viewed_user: - context['user_pronounish'] = 'You' - context['user_verbform_be'] = 'are' + context["user_pronounish"] = "You" + context["user_verbform_be"] = "are" else: - context['user_pronounish'] = 'This user' - context['user_verbform_be'] = 'is' + context["user_pronounish"] = "This user" + context["user_verbform_be"] = "is" return context class UserUpgradeAccount(LoginRequiredMixin, UserPassesTestMixin, View): - def test_func(self): return True def dispatch(self, request, *args, **kwargs): if request.user.is_superuser: - messages.error(request, 'You are already a super user') - return HttpResponseRedirect(reverse('user-profile')) + messages.error(request, "You are already a super user") + return HttpResponseRedirect(reverse("user-profile")) if request.user.userprofile.is_pi: - messages.error(request, 'Your account has already been upgraded') - return HttpResponseRedirect(reverse('user-profile')) + messages.error(request, "Your account has already been upgraded") + return HttpResponseRedirect(reverse("user-profile")) return super().dispatch(request, *args, **kwargs) def post(self, request): - if EMAIL_ENABLED: - send_email_template( - 'Upgrade Account Request', - 'email/upgrade_account_request.txt', - {'user': request.user}, - request.user.email, - [EMAIL_TICKET_SYSTEM_ADDRESS] - ) + send_email_template( + "Upgrade Account Request", + "email/upgrade_account_request.txt", + {"user": request.user}, + [EMAIL_TICKET_SYSTEM_ADDRESS], + ) - messages.success(request, 'Your request has been sent') - return HttpResponseRedirect(reverse('user-profile')) + messages.success(request, "Your request has been sent") + return HttpResponseRedirect(reverse("user-profile")) class UserSearchHome(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'user/user_search_home.html' + template_name = "user/user_search_home.html" def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['user_search_form'] = UserSearchForm() + context["user_search_form"] = UserSearchForm() return context def test_func(self): @@ -229,16 +241,15 @@ def test_func(self): class UserSearchResults(LoginRequiredMixin, UserPassesTestMixin, View): - template_name = 'user/user_search_results.html' + template_name = "user/user_search_results.html" raise_exception = True def post(self, request): - user_search_string = request.POST.get('q') + user_search_string = request.POST.get("q") - search_by = request.POST.get('search_by') + search_by = request.POST.get("search_by") - cobmined_user_search_obj = CombinedUserSearch( - user_search_string, search_by) + cobmined_user_search_obj = CombinedUserSearch(user_search_string, search_by) context = cobmined_user_search_obj.search() return render(request, self.template_name, context) @@ -248,7 +259,7 @@ def test_func(self): class UserListAllocations(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'user/user_list_allocations.html' + template_name = "user/user_list_allocations.html" def test_func(self): return self.request.user.is_superuser or self.request.user.userprofile.is_pi @@ -259,13 +270,26 @@ def get_context_data(self, *args, **kwargs): user_dict = {} for project in Project.objects.filter(pi=self.request.user): - for allocation in project.allocation_set.filter(status__name='Active'): - for allocation_user in allocation.allocationuser_set.filter(status__name='Active').order_by('user__username'): + for allocation in project.allocation_set.filter(status__name="Active"): + for allocation_user in allocation.allocationuser_set.filter(status__name="Active").order_by( + "user__username" + ): if allocation_user.user not in user_dict: user_dict[allocation_user.user] = [] user_dict[allocation_user.user].append(allocation) - context['user_dict'] = user_dict + context["user_dict"] = user_dict return context + + +class HtmxLogoutView(LogoutView): + def post(self, request, *args, **kwargs): + auth_logout(request) + redirect_to = self.get_success_url() + if redirect_to != request.get_full_path(): + response = HttpResponse(status=204) + response["HX-Redirect"] = redirect_to + return response + return super().get(request, *args, **kwargs) diff --git a/coldfront/core/utils/__init__.py b/coldfront/core/utils/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/__init__.py +++ b/coldfront/core/utils/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/admin.py b/coldfront/core/utils/admin.py index 8c38f3f3da..97070bc06b 100644 --- a/coldfront/core/utils/admin.py +++ b/coldfront/core/utils/admin.py @@ -1,3 +1,5 @@ -from django.contrib import admin +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Register your models here. diff --git a/coldfront/core/utils/apps.py b/coldfront/core/utils/apps.py index 16ceeb1a3a..68d0c7f8b7 100644 --- a/coldfront/core/utils/apps.py +++ b/coldfront/core/utils/apps.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class UtilsConfig(AppConfig): - name = 'coldfront.core.utils' - verbose_name = 'Coldfront Utils' + name = "coldfront.core.utils" + verbose_name = "Coldfront Utils" diff --git a/coldfront/core/utils/common.py b/coldfront/core/utils/common.py index f1c05bc1e2..9fd84a2de3 100644 --- a/coldfront/core/utils/common.py +++ b/coldfront/core/utils/common.py @@ -1,4 +1,7 @@ -import datetime +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # import the logging library import logging @@ -21,11 +24,11 @@ def import_from_settings(attr, *args): return getattr(settings, attr, args[0]) return getattr(settings, attr) except AttributeError: - raise ImproperlyConfigured('Setting {0} not found'.format(attr)) + raise ImproperlyConfigured("Setting {0} not found".format(attr)) def get_domain_url(request): - return request.build_absolute_uri().replace(request.get_full_path(), '') + return request.build_absolute_uri().replace(request.get_full_path(), "") class Echo: @@ -39,11 +42,9 @@ def write(self, value): def su_login_callback(user): - """Only superusers are allowed to login as other users - """ + """Only superusers are allowed to login as other users""" if user.is_active and user.is_superuser: return True - logger.warn( - 'User {} requested to login as another user but does not have permissions', user) + logger.warning("User {} requested to login as another user but does not have permissions", user) return False diff --git a/coldfront/core/utils/fixtures/__init__.py b/coldfront/core/utils/fixtures/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/fixtures/__init__.py +++ b/coldfront/core/utils/fixtures/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/mail.py b/coldfront/core/utils/mail.py index 286cab4805..340cfc69e0 100644 --- a/coldfront/core/utils/mail.py +++ b/coldfront/core/utils/mail.py @@ -1,41 +1,45 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging from smtplib import SMTPException from django.conf import settings -from django.core.mail import EmailMessage, send_mail +from django.core.mail import EmailMessage from django.template.loader import render_to_string from django.urls import reverse from coldfront.core.utils.common import import_from_settings logger = logging.getLogger(__name__) -EMAIL_ENABLED = import_from_settings('EMAIL_ENABLED', False) -EMAIL_SUBJECT_PREFIX = import_from_settings('EMAIL_SUBJECT_PREFIX') -EMAIL_DEVELOPMENT_EMAIL_LIST = import_from_settings('EMAIL_DEVELOPMENT_EMAIL_LIST') -EMAIL_SENDER = import_from_settings('EMAIL_SENDER') -EMAIL_TICKET_SYSTEM_ADDRESS = import_from_settings('EMAIL_TICKET_SYSTEM_ADDRESS') -EMAIL_OPT_OUT_INSTRUCTION_URL = import_from_settings('EMAIL_OPT_OUT_INSTRUCTION_URL') -EMAIL_SIGNATURE = import_from_settings('EMAIL_SIGNATURE') -EMAIL_CENTER_NAME = import_from_settings('CENTER_NAME') -CENTER_BASE_URL = import_from_settings('CENTER_BASE_URL') - -def send_email(subject, body, sender, receiver_list, cc=[]): - """Helper function for sending emails - """ +EMAIL_ENABLED = import_from_settings("EMAIL_ENABLED", False) +EMAIL_SUBJECT_PREFIX = import_from_settings("EMAIL_SUBJECT_PREFIX") +EMAIL_DEVELOPMENT_EMAIL_LIST = import_from_settings("EMAIL_DEVELOPMENT_EMAIL_LIST") +EMAIL_SENDER = import_from_settings("EMAIL_SENDER") +EMAIL_TICKET_SYSTEM_ADDRESS = import_from_settings("EMAIL_TICKET_SYSTEM_ADDRESS") +EMAIL_OPT_OUT_INSTRUCTION_URL = import_from_settings("EMAIL_OPT_OUT_INSTRUCTION_URL") +EMAIL_SIGNATURE = import_from_settings("EMAIL_SIGNATURE") +EMAIL_CENTER_NAME = import_from_settings("CENTER_NAME") +CENTER_BASE_URL = import_from_settings("CENTER_BASE_URL") + + +def send_email(subject, body, sender, receiver_list, cc=None): + """Helper function for sending emails""" if not EMAIL_ENABLED: return if len(receiver_list) == 0: - logger.error('Failed to send email missing receiver_list') + logger.error("Failed to send email: missing receiver_list") return if len(sender) == 0: - logger.error('Failed to send email missing sender address') + logger.error("Failed to send email: missing sender address") return if len(EMAIL_SUBJECT_PREFIX) > 0: - subject = EMAIL_SUBJECT_PREFIX + ' ' + subject + subject = EMAIL_SUBJECT_PREFIX + " " + subject if settings.DEBUG: receiver_list = EMAIL_DEVELOPMENT_EMAIL_LIST @@ -44,94 +48,123 @@ def send_email(subject, body, sender, receiver_list, cc=[]): cc = EMAIL_DEVELOPMENT_EMAIL_LIST try: - if cc: - email = EmailMessage( - subject, - body, - sender, - receiver_list, - cc=cc) - email.send(fail_silently=False) - else: - send_mail(subject, body, sender, - receiver_list, fail_silently=False) - except SMTPException as e: - logger.error('Failed to send email to %s from %s with subject %s', - sender, ','.join(receiver_list), subject) - - -def send_email_template(subject, template_name, template_context, sender, receiver_list): - """Helper function for sending emails from a template + email = EmailMessage(subject, body, sender, receiver_list, cc=cc) + email.send(fail_silently=False) + except SMTPException: + logger.error("Failed to send email from %s to %s with subject %s", sender, ",".join(receiver_list), subject) + + +def send_email_template(subject, template_name, template_context, receiver_list, sender=EMAIL_SENDER, cc=None): + """Helper function for sending emails from a template. + + Args: + subject: The email subject. + template_name: The name of the template to render. + template_context: A dict containing the context to pass into the template. + receiver_list: A list of recipients. + sender_email: The email to send from. Defaults to EMAIL_SENDER. + cc: Email address(es) to be cc'd. Can be a string or list. """ - if not EMAIL_ENABLED: - return + ctx = email_template_context() + ctx.update(template_context) + + body = render_to_string(template_name, ctx) - body = render_to_string(template_name, template_context) + return send_email(subject, body, sender, receiver_list, cc=cc) - return send_email(subject, body, sender, receiver_list) def email_template_context(): - """Basic email template context used as base for all templates - """ + """Basic email template context used as base for all templates""" return { - 'center_name': EMAIL_CENTER_NAME, - 'signature': EMAIL_SIGNATURE, - 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL + "center_name": EMAIL_CENTER_NAME, + "signature": EMAIL_SIGNATURE, + "opt_out_instruction_url": EMAIL_OPT_OUT_INSTRUCTION_URL, + "center_base_url": CENTER_BASE_URL, } -def build_link(url_path, domain_url=''): + +def build_link(url_path, domain_url=""): if not domain_url: domain_url = CENTER_BASE_URL - return f'{domain_url}{url_path}' + return f"{domain_url}{url_path}" + def send_admin_email_template(subject, template_name, template_context): - """Helper function for sending admin emails using a template - """ - send_email_template(subject, template_name, template_context, EMAIL_SENDER, [EMAIL_TICKET_SYSTEM_ADDRESS, ]) + """Helper function for sending admin emails using a template""" + send_email_template( + subject, + template_name, + template_context, + [EMAIL_TICKET_SYSTEM_ADDRESS], + ) -def send_allocation_admin_email(allocation_obj, subject, template_name, url_path='', domain_url=''): - """Send allocation admin emails - """ + +def send_allocation_admin_email(allocation_obj, subject, template_name, url_path="", domain_url=""): + """Send allocation admin emails""" if not url_path: - url_path = reverse('allocation-request-list') + url_path = reverse("allocation-request-list") url = build_link(url_path, domain_url=domain_url) - pi_name = f'{allocation_obj.project.pi.first_name} {allocation_obj.project.pi.last_name} ({allocation_obj.project.pi.username})' + pi_name = f"{allocation_obj.project.pi.first_name} {allocation_obj.project.pi.last_name} ({allocation_obj.project.pi.username})" resource_name = allocation_obj.get_parent_resource ctx = email_template_context() - ctx['pi'] = pi_name - ctx['resource'] = resource_name - ctx['url'] = url + ctx["pi"] = pi_name + ctx["resource"] = resource_name + ctx["url"] = url send_admin_email_template( - f'{subject}: {pi_name} - {resource_name}', + f"{subject}: {pi_name} - {resource_name}", template_name, ctx, ) -def send_allocation_customer_email(allocation_obj, subject, template_name, url_path='', domain_url=''): - """Send allocation customer emails - """ + +def send_allocation_customer_email(allocation_obj, subject, template_name, url_path="", domain_url=""): + """Send allocation customer emails""" if not url_path: - url_path = reverse('allocation-detail', kwargs={'pk': allocation_obj.pk}) + url_path = reverse("allocation-detail", kwargs={"pk": allocation_obj.pk}) url = build_link(url_path, domain_url=domain_url) ctx = email_template_context() - ctx['resource'] = allocation_obj.get_parent_resource - ctx['url'] = url + ctx["resource"] = allocation_obj.get_parent_resource + ctx["url"] = url - allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=['Removed', 'Error']) + allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=["Removed", "Error"]) email_receiver_list = [] for allocation_user in allocation_users: - if allocation_user.allocation.project.projectuser_set.get( - user=allocation_user.user).enable_notifications: + if allocation_user.allocation.project.projectuser_set.get(user=allocation_user.user).enable_notifications: email_receiver_list.append(allocation_user.user.email) - send_email_template( - subject, - template_name, - ctx, - EMAIL_SENDER, - email_receiver_list + send_email_template(subject, template_name, ctx, email_receiver_list) + + +def send_allocation_eula_customer_email( + allocation_user, subject, template_name, url_path="", domain_url="", cc_managers=False, include_eula=False +): + """Send allocation customer emails""" + + allocation_obj = allocation_user.allocation + if not url_path: + url_path = reverse("allocation-review-eula", kwargs={"pk": allocation_obj.pk}) + + url = build_link(url_path, domain_url=domain_url) + ctx = email_template_context() + ctx["resource"] = allocation_obj.get_parent_resource + ctx["url"] = url + ctx["allocation_user"] = "{} {} ({})".format( + allocation_user.user.first_name, allocation_user.user.last_name, allocation_user.user.username ) + if include_eula: + ctx["eula"] = allocation_obj.get_eula() + + email_receiver_list = [allocation_user.user.email] + email_cc_list = [] + if cc_managers: + project_obj = allocation_obj.project + managers = project_obj.projectuser_set.filter(role__name="Manager", status__name="Active") + for manager in managers: + if manager.enable_notifications: + email_cc_list.append(manager.user.email) + + send_email_template(subject, template_name, ctx, email_receiver_list, cc=email_cc_list) diff --git a/coldfront/core/utils/management/__init__.py b/coldfront/core/utils/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/management/__init__.py +++ b/coldfront/core/utils/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/management/commands/__init__.py b/coldfront/core/utils/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/management/commands/__init__.py +++ b/coldfront/core/utils/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/management/commands/add_scheduled_tasks.py b/coldfront/core/utils/management/commands/add_scheduled_tasks.py index 0f78c8ea79..9da270fb4b 100644 --- a/coldfront/core/utils/management/commands/add_scheduled_tasks.py +++ b/coldfront/core/utils/management/commands/add_scheduled_tasks.py @@ -1,25 +1,31 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime -import os from django.conf import settings -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from django.utils import timezone from django_q.models import Schedule from django_q.tasks import schedule +from coldfront.config.email import EMAIL_ALLOCATION_EULA_REMINDERS +from coldfront.core.utils.common import import_from_settings + +ALLOCATION_EULA_ENABLE = import_from_settings("ALLOCATION_EULA_ENABLE", False) base_dir = settings.BASE_DIR class Command(BaseCommand): - def handle(self, *args, **options): - date = timezone.now() + datetime.timedelta(days=1) date = date.replace(hour=0, minute=0, second=0, microsecond=0) - schedule('coldfront.core.allocation.tasks.update_statuses', - schedule_type=Schedule.DAILY, - next_run=date) + schedule("coldfront.core.allocation.tasks.update_statuses", schedule_type=Schedule.DAILY, next_run=date) + + schedule("coldfront.core.allocation.tasks.send_expiry_emails", schedule_type=Schedule.DAILY, next_run=date) - schedule('coldfront.core.allocation.tasks.send_expiry_emails', - schedule_type=Schedule.DAILY, - next_run=date) + if ALLOCATION_EULA_ENABLE and EMAIL_ALLOCATION_EULA_REMINDERS: + schedule( + "coldfront.core.allocation.tasks.send_eula_reminders", schedule_type=Schedule.WEEKLY, next_run=date + ) diff --git a/coldfront/core/utils/management/commands/initial_setup.py b/coldfront/core/utils/management/commands/initial_setup.py index 421c2712c2..fb02cdddb3 100644 --- a/coldfront/core/utils/management/commands/initial_setup.py +++ b/coldfront/core/utils/management/commands/initial_setup.py @@ -1,4 +1,6 @@ -import os +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later from django.conf import settings from django.core.management import call_command @@ -8,31 +10,37 @@ class Command(BaseCommand): - help = 'Run setup script to initialize the Coldfront database' + help = "Run setup script to initialize the Coldfront database" def add_arguments(self, parser): - parser.add_argument("-f", "--force_overwrite", help="Force intial_setup script to run with no warning.", action="store_true") + parser.add_argument( + "-f", "--force_overwrite", help="Force initial_setup script to run with no warning.", action="store_true" + ) def handle(self, *args, **options): - if options['force_overwrite']: + if options["force_overwrite"]: + run_setup() + + else: + self.stdout.write( + self.style.WARNING( + """WARNING: Running this command initializes the ColdFront database and may modify/delete data in your existing ColdFront database. This command is typically only run once.""" + ) + ) + user_response = input("Do you want to proceed?(yes):") + + if user_response == "yes": run_setup() - else: - print("""WARNING: Running this command initializes the ColdFront database and may modify/delete data in your existing ColdFront database. This command is typically only run once.""") - user_response = input("Do you want to proceed?(yes):") - - if user_response == "yes": - run_setup() - else: - print("Please enter 'yes' if you wish to run intital setup.") + self.stdout.write("Please enter 'yes' if you wish to run initial setup.") -def run_setup(): - call_command('migrate') - call_command('import_field_of_science_data') - call_command('add_default_grant_options') - call_command('add_default_project_choices') - call_command('add_resource_defaults') - call_command('add_allocation_defaults') - call_command('add_default_publication_sources') - call_command('add_scheduled_tasks') +def run_setup(): + call_command("migrate") + call_command("import_field_of_science_data") + call_command("add_default_grant_options") + call_command("add_default_project_choices") + call_command("add_resource_defaults") + call_command("add_allocation_defaults") + call_command("add_default_publication_sources") + call_command("add_scheduled_tasks") diff --git a/coldfront/core/utils/management/commands/load_test_data.py b/coldfront/core/utils/management/commands/load_test_data.py index c21f03649e..26923877ab 100644 --- a/coldfront/core/utils/management/commands/load_test_data.py +++ b/coldfront/core/utils/management/commands/load_test_data.py @@ -1,175 +1,184 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime -import os from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth.models import User -from django.core.management import call_command from django.core.management.base import BaseCommand -from coldfront.core.allocation.models import (Allocation, AllocationAttribute, - AllocationAttributeType, - AllocationStatusChoice, - AllocationUser, - AllocationUserStatusChoice) +from coldfront.core.allocation.models import ( + Allocation, + AllocationAttribute, + AllocationAttributeType, + AllocationStatusChoice, + AllocationUser, + AllocationUserStatusChoice, +) from coldfront.core.field_of_science.models import FieldOfScience -from coldfront.core.grant.models import (Grant, GrantFundingAgency, - GrantStatusChoice) -from coldfront.core.project.models import (Project, ProjectStatusChoice, - ProjectUser, ProjectUserRoleChoice, - ProjectUserStatusChoice, ProjectAttribute - , ProjectAttributeType, AttributeType) +from coldfront.core.grant.models import Grant, GrantFundingAgency, GrantStatusChoice +from coldfront.core.project.models import ( + AttributeType, + Project, + ProjectAttribute, + ProjectAttributeType, + ProjectStatusChoice, + ProjectUser, + ProjectUserRoleChoice, + ProjectUserStatusChoice, +) from coldfront.core.publication.models import Publication, PublicationSource -from coldfront.core.resource.models import (Resource, ResourceAttribute, - ResourceAttributeType, - ResourceType) -from coldfront.core.user.models import UserProfile +from coldfront.core.resource.models import Resource, ResourceAttribute, ResourceAttributeType, ResourceType base_dir = settings.BASE_DIR # first, last -Users = ['Carl Gray', # PI#1 - 'Stephanie Foster', # PI#2 - 'Charles Simmons', # Director - 'Andrea Stewart', - 'Alice Rivera', - 'Frank Hernandez', - 'Justin James', - 'Randy Perry', - 'Carol Lee', - 'Susan Hughes', - 'Jose Martin', - 'Joe Roberts', - 'Howard Nelson', - 'Patricia Moore', - 'Jessica Alexander', - 'Jesse Russell', - 'Shirley Price', - 'Julie Phillips', - 'Kathy Jenkins', - 'James Hill', - 'Tammy Howard', - 'Lisa Coleman', - 'Denise Adams', - 'Shawn Williams', - 'Ernest Reed', - 'Larry Ramirez', - 'Kathleen Garcia', - 'Jennifer Jones', - 'Irene Anderson', - 'Beverly Mitchell', - 'Peter Patterson', - 'Eugene Griffin', - 'Jimmy Lewis', - 'Margaret Turner', - 'Julia Peterson', - 'Amanda Johnson', - 'Christina Morris', - 'Cynthia Carter', - 'Wayne Murphy', - 'Ronald Sanders', - 'Lillian Bell', - 'Harold Lopez', - 'Roger Wilson', - 'Jane Edwards', - 'Billy Perez', - 'Jane Butler', - 'John Smith', - 'John Long', - 'Jane Martinez', - 'John Cooper', ] +Users = [ + "Carl Gray", # PI#1 + "Stephanie Foster", # PI#2 + "Charles Simmons", # Director + "Andrea Stewart", + "Alice Rivera", + "Frank Hernandez", + "Justin James", + "Randy Perry", + "Carol Lee", + "Susan Hughes", + "Jose Martin", + "Joe Roberts", + "Howard Nelson", + "Patricia Moore", + "Jessica Alexander", + "Jesse Russell", + "Shirley Price", + "Julie Phillips", + "Kathy Jenkins", + "James Hill", + "Tammy Howard", + "Lisa Coleman", + "Denise Adams", + "Shawn Williams", + "Ernest Reed", + "Larry Ramirez", + "Kathleen Garcia", + "Jennifer Jones", + "Irene Anderson", + "Beverly Mitchell", + "Peter Patterson", + "Eugene Griffin", + "Jimmy Lewis", + "Margaret Turner", + "Julia Peterson", + "Amanda Johnson", + "Christina Morris", + "Cynthia Carter", + "Wayne Murphy", + "Ronald Sanders", + "Lillian Bell", + "Harold Lopez", + "Roger Wilson", + "Jane Edwards", + "Billy Perez", + "Jane Butler", + "John Smith", + "John Long", + "Jane Martinez", + "John Cooper", +] dois = [ - '10.1016/j.nuclphysb.2014.08.011', - '10.1103/PhysRevB.81.014411', - '10.1103/PhysRevB.82.014421', - '10.1103/PhysRevB.83.014401', - '10.1103/PhysRevB.84.014503', - '10.1103/PhysRevB.85.014111', - '10.1103/PhysRevB.92.014205', - '10.1103/PhysRevB.91.140409', + "10.1016/j.nuclphysb.2014.08.011", + "10.1103/PhysRevB.81.014411", + "10.1103/PhysRevB.82.014421", + "10.1103/PhysRevB.83.014401", + "10.1103/PhysRevB.84.014503", + "10.1103/PhysRevB.85.014111", + "10.1103/PhysRevB.92.014205", + "10.1103/PhysRevB.91.140409", ] # resource_type, parent_resource, name, description, is_available, is_public, is_allocatable resources = [ - # Clusters - ('Cluster', None, 'University HPC', - 'University Academic Cluster', True, True, True), - ('Cluster', None, 'Chemistry', 'Chemistry Cluster', True, False, False), - ('Cluster', None, 'Physics', 'Physics Cluster', True, False, False), - ('Cluster', None, 'Industry', 'Industry Cluster', True, False, False), - ('Cluster', None, 'University Metered HPC', 'SU metered Cluster', - True, True, True), - + ("Cluster", None, "University HPC", "University Academic Cluster", True, True, True), + ("Cluster", None, "Chemistry", "Chemistry Cluster", True, False, False), + ("Cluster", None, "Physics", "Physics Cluster", True, False, False), + ("Cluster", None, "Industry", "Industry Cluster", True, False, False), + ("Cluster", None, "University Metered HPC", "SU metered Cluster", True, True, True), # Cluster Partitions scavengers - ('Cluster Partition', 'Chemistry', 'Chemistry-scavenger', - 'Scavenger partition on Chemistry cluster', True, False, False), - ('Cluster Partition', 'Physics', 'Physics-scavenger', - 'Scavenger partition on Physics cluster', True, False, False), - ('Cluster Partition', 'Industry', 'Industry-scavenger', - 'Scavenger partition on Industry cluster', True, False, False), - + ( + "Cluster Partition", + "Chemistry", + "Chemistry-scavenger", + "Scavenger partition on Chemistry cluster", + True, + False, + False, + ), + ("Cluster Partition", "Physics", "Physics-scavenger", "Scavenger partition on Physics cluster", True, False, False), + ( + "Cluster Partition", + "Industry", + "Industry-scavenger", + "Scavenger partition on Industry cluster", + True, + False, + False, + ), # Cluster Partitions Users - ('Cluster Partition', 'Chemistry', 'Chemistry-cgray', - "Carl Gray's nodes", True, False, True), - ('Cluster Partition', 'Physics', 'Physics-sfoster', - "Stephanie Foster's nodes", True, False, True), - + ("Cluster Partition", "Chemistry", "Chemistry-cgray", "Carl Gray's nodes", True, False, True), + ("Cluster Partition", "Physics", "Physics-sfoster", "Stephanie Foster's nodes", True, False, True), # Servers - ('Server', None, 'server-cgray', - "Server for Carl Gray's research lab", True, False, True), - ('Server', None, 'server-sfoster', - "Server for Stephanie Foster's research lab", True, False, True), - + ("Server", None, "server-cgray", "Server for Carl Gray's research lab", True, False, True), + ("Server", None, "server-sfoster", "Server for Stephanie Foster's research lab", True, False, True), # Storage - ('Storage', None, 'Budgetstorage', - 'Low-tier storage option - NOT BACKED UP', True, True, True), - ('Storage', None, 'ProjectStorage', - 'Enterprise-level storage - BACKED UP DAILY', True, True, True), - + ("Storage", None, "Budgetstorage", "Low-tier storage option - NOT BACKED UP", True, True, True), + ("Storage", None, "ProjectStorage", "Enterprise-level storage - BACKED UP DAILY", True, True, True), # Cloud - ('Cloud', None, 'University Cloud', - 'University Research Cloud', True, True, True), - ('Storage', 'University Cloud', 'University Cloud Storage', - 'Storage available to cloud instances', True, True, True), - + ("Cloud", None, "University Cloud", "University Research Cloud", True, True, True), + ( + "Storage", + "University Cloud", + "University Cloud Storage", + "Storage available to cloud instances", + True, + True, + True, + ), ] class Command(BaseCommand): - def handle(self, *args, **options): - for user in Users: first_name, last_name = user.split() - username = first_name[0].lower()+last_name.lower().strip() - email = username + '@example.com' + username = first_name[0].lower() + last_name.lower().strip() + email = username + "@example.com" User.objects.get_or_create( first_name=first_name.strip(), last_name=last_name.strip(), username=username.strip(), - email=email.strip() + email=email.strip(), ) - admin_user, _ = User.objects.get_or_create(username='admin') + admin_user, _ = User.objects.get_or_create(username="admin") admin_user.is_superuser = True admin_user.is_staff = True admin_user.save() for user in User.objects.all(): - user.set_password('test1234') + user.set_password("test1234") user.save() for resource in resources: - resource_type, parent_resource, name, description, is_available, is_public, is_allocatable = resource resource_type_obj = ResourceType.objects.get(name=resource_type) - if parent_resource != None: - parent_resource_obj = Resource.objects.get( - name=parent_resource) + if parent_resource is not None: + parent_resource_obj = Resource.objects.get(name=parent_resource) else: parent_resource_obj = None @@ -180,62 +189,63 @@ def handle(self, *args, **options): description=description, is_available=is_available, is_public=is_public, - is_allocatable=is_allocatable + is_allocatable=is_allocatable, ) - resource_obj = Resource.objects.get(name='server-cgray') - resource_obj.allowed_users.add(User.objects.get(username='cgray')) - resource_obj = Resource.objects.get(name='server-sfoster') - resource_obj.allowed_users.add(User.objects.get(username='sfoster')) + resource_obj = Resource.objects.get(name="server-cgray") + resource_obj.allowed_users.add(User.objects.get(username="cgray")) + resource_obj = Resource.objects.get(name="server-sfoster") + resource_obj.allowed_users.add(User.objects.get(username="sfoster")) - pi1 = User.objects.get(username='cgray') + pi1 = User.objects.get(username="cgray") pi1.userprofile.is_pi = True pi1.save() project_obj, _ = Project.objects.get_or_create( pi=pi1, - title='Angular momentum in QGP holography', - description='We want to estimate the quark chemical potential of a rotating sample of plasma.', - field_of_science=FieldOfScience.objects.get( - description='Chemistry'), - status=ProjectStatusChoice.objects.get(name='Active'), - force_review=True + title="Angular momentum in QGP holography", + description="We want to estimate the quark chemical potential of a rotating sample of plasma.", + field_of_science=FieldOfScience.objects.get(description="Chemistry"), + status=ProjectStatusChoice.objects.get(name="Active"), + force_review=True, ) - AttributeType.objects.get_or_create( - name='Int' - ) + AttributeType.objects.get_or_create(name="Int") ProjectAttributeType.objects.get_or_create( - attribute_type=AttributeType.objects.get(name='Text'), - name='Project ID', + attribute_type=AttributeType.objects.get(name="Text"), + name="Project ID", is_private=False, ) ProjectAttributeType.objects.get_or_create( - attribute_type=AttributeType.objects.get(name='Int'), - name='Account Number', + attribute_type=AttributeType.objects.get(name="Int"), + name="Account Number", is_private=True, ) ProjectAttribute.objects.get_or_create( - proj_attr_type=ProjectAttributeType.objects.get(name='Project ID'), + proj_attr_type=ProjectAttributeType.objects.get(name="Project ID"), project=project_obj, value=1242021, ) ProjectAttribute.objects.get_or_create( - proj_attr_type=ProjectAttributeType.objects.get(name='Account Number'), + proj_attr_type=ProjectAttributeType.objects.get(name="Account Number"), project=project_obj, value=1756522, ) - univ_hpc = Resource.objects.get(name='University HPC') - for scavanger in ('Chemistry-scavenger', 'Physics-scavenger', 'Industry-scavenger', ): + univ_hpc = Resource.objects.get(name="University HPC") + for scavanger in ( + "Chemistry-scavenger", + "Physics-scavenger", + "Industry-scavenger", + ): resource_obj = Resource.objects.get(name=scavanger) univ_hpc.linked_resources.add(resource_obj) univ_hpc.save() - publication_source = PublicationSource.objects.get(name='doi') + publication_source = PublicationSource.objects.get(name="doi") # for title, author, year, unique_id, source in ( # ('Angular momentum in QGP holography', 'Brett McInnes', # 2014, '10.1016/j.nuclphysb.2014.08.011', 'doi'), @@ -289,8 +299,8 @@ def handle(self, *args, **options): project_user_obj, _ = ProjectUser.objects.get_or_create( user=pi1, project=project_obj, - role=ProjectUserRoleChoice.objects.get(name='Manager'), - status=ProjectUserStatusChoice.objects.get(name='Active') + role=ProjectUserRoleChoice.objects.get(name="Manager"), + status=ProjectUserStatusChoice.objects.get(name="Active"), ) start_date = datetime.datetime.now() @@ -299,188 +309,164 @@ def handle(self, *args, **options): # Add PI cluster allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=end_date, is_changeable=True, - justification='I need access to my nodes.' + justification="I need access to my nodes.", ) - allocation_obj.resources.add( - Resource.objects.get(name='Chemistry-cgray')) + allocation_obj.resources.add(Resource.objects.get(name="Chemistry-cgray")) allocation_obj.save() - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_account_name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_account_name") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='cgray') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="cgray" + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_user_specs') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_user_specs") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='Fairshare=parent') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="Fairshare=parent" + ) - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi1, - status=AllocationUserStatusChoice.objects.get(name='Active') + AllocationUser.objects.create( + allocation=allocation_obj, user=pi1, status=AllocationUserStatusChoice.objects.get(name="Active") ) # Add university cluster allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=datetime.datetime.now() + relativedelta(days=10), is_changeable=True, - justification='I need access to university cluster.' + justification="I need access to university cluster.", ) - allocation_obj.resources.add( - Resource.objects.get(name='University HPC')) + allocation_obj.resources.add(Resource.objects.get(name="University HPC")) allocation_obj.save() - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_specs') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_specs") AllocationAttribute.objects.get_or_create( allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, - value='Fairshare=100:QOS+=supporters') + value="Fairshare=100:QOS+=supporters", + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_user_specs') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_user_specs") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='Fairshare=parent') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="Fairshare=parent" + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_account_name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_account_name") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='cgray') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="cgray" + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='SupportersQOS') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="SupportersQOS") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='Yes') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="Yes" + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='SupportersQOSExpireDate') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="SupportersQOSExpireDate") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='2022-01-01') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="2022-01-01" + ) - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi1, - status=AllocationUserStatusChoice.objects.get(name='Active') + AllocationUser.objects.create( + allocation=allocation_obj, user=pi1, status=AllocationUserStatusChoice.objects.get(name="Active") ) # Add project storage allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=end_date, quantity=10, is_changeable=True, - justification='I need extra storage.' + justification="I need extra storage.", ) - allocation_obj.resources.add( - Resource.objects.get(name='Budgetstorage')) + allocation_obj.resources.add(Resource.objects.get(name="Budgetstorage")) allocation_obj.save() - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi1, - status=AllocationUserStatusChoice.objects.get(name='Active') + AllocationUser.objects.create( + allocation=allocation_obj, user=pi1, status=AllocationUserStatusChoice.objects.get(name="Active") ) # Add metered allocation allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=end_date, is_changeable=True, - justification='I need compute time on metered cluster.' + justification="I need compute time on metered cluster.", ) - allocation_obj.resources.add( - Resource.objects.get(name='University Metered HPC')) + allocation_obj.resources.add(Resource.objects.get(name="University Metered HPC")) allocation_obj.save() - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_account_name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_account_name") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='cgray-metered') - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Core Usage (Hours)') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="cgray-metered" + ) + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Core Usage (Hours)") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='150000') - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi1, - status=AllocationUserStatusChoice.objects.get(name='Active') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="150000" + ) + AllocationUser.objects.create( + allocation=allocation_obj, user=pi1, status=AllocationUserStatusChoice.objects.get(name="Active") ) - - pi2 = User.objects.get(username='sfoster') + pi2 = User.objects.get(username="sfoster") pi2.userprofile.is_pi = True pi2.save() project_obj, _ = Project.objects.get_or_create( pi=pi2, - title='Measuring critical behavior of quantum Hall transitions', - description='This purpose of this project is to measure the critical behavior of quantum Hall transitions.', - field_of_science=FieldOfScience.objects.get(description='Physics'), - status=ProjectStatusChoice.objects.get(name='Active') + title="Measuring critical behavior of quantum Hall transitions", + description="This purpose of this project is to measure the critical behavior of quantum Hall transitions.", + field_of_science=FieldOfScience.objects.get(description="Physics"), + status=ProjectStatusChoice.objects.get(name="Active"), ) project_user_obj, _ = ProjectUser.objects.get_or_create( user=pi2, project=project_obj, - role=ProjectUserRoleChoice.objects.get(name='Manager'), - status=ProjectUserStatusChoice.objects.get(name='Active') + role=ProjectUserRoleChoice.objects.get(name="Manager"), + status=ProjectUserStatusChoice.objects.get(name="Active"), ) for title, author, year, journal, unique_id, source in ( - ('Lattice constants from semilocal density functionals with zero-point phonon correction', - "Pan Hao and Yuan Fang and Jianwei Sun and G\'abor I. Csonka and Pier H. T. Philipsen and John P. Perdew", - 2012, - 'Physical Review B', - '10.1103/PhysRevB.85.014111', - 'doi'), - ('Anisotropic magnetocapacitance in ferromagnetic-plate capacitors', - "J. A. Haigh and C. Ciccarelli and A. C. Betz and A. Irvine and V. Nov\'ak and T. Jungwirth and J. Wunderlich", - 2015, - 'Physical Review B', - '10.1103/PhysRevB.91.140409', - 'doi' - ), - ('Interaction effects in topological superconducting wires supporting Majorana fermions', - 'E. M. Stoudenmire and Jason Alicea and Oleg A. Starykh and Matthew P.A. Fisher', - 2011, - 'Physical Review B', - '10.1103/PhysRevB.84.014503', - 'doi' - ), - ('Logarithmic correlations in quantum Hall plateau transitions', - 'Romain Vasseur', - 2015, - 'Physical Review B', - '10.1103/PhysRevB.92.014205', - 'doi' - ), + ( + "Lattice constants from semilocal density functionals with zero-point phonon correction", + "Pan Hao and Yuan Fang and Jianwei Sun and G'abor I. Csonka and Pier H. T. Philipsen and John P. Perdew", + 2012, + "Physical Review B", + "10.1103/PhysRevB.85.014111", + "doi", + ), + ( + "Anisotropic magnetocapacitance in ferromagnetic-plate capacitors", + "J. A. Haigh and C. Ciccarelli and A. C. Betz and A. Irvine and V. Nov'ak and T. Jungwirth and J. Wunderlich", + 2015, + "Physical Review B", + "10.1103/PhysRevB.91.140409", + "doi", + ), + ( + "Interaction effects in topological superconducting wires supporting Majorana fermions", + "E. M. Stoudenmire and Jason Alicea and Oleg A. Starykh and Matthew P.A. Fisher", + 2011, + "Physical Review B", + "10.1103/PhysRevB.84.014503", + "doi", + ), + ( + "Logarithmic correlations in quantum Hall plateau transitions", + "Romain Vasseur", + 2015, + "Physical Review B", + "10.1103/PhysRevB.92.014205", + "doi", + ), ): Publication.objects.get_or_create( project=project_obj, @@ -489,7 +475,7 @@ def handle(self, *args, **options): year=year, journal=journal, unique_id=unique_id, - source=publication_source + source=publication_source, ) start_date = datetime.datetime.now() @@ -497,157 +483,204 @@ def handle(self, *args, **options): Grant.objects.get_or_create( project=project_obj, - title='Quantum Halls', - grant_number='12345', - role='PI', - grant_pi_full_name='Stephanie Foster', - funding_agency=GrantFundingAgency.objects.get( - name='Department of Defense (DoD)'), + title="Quantum Halls", + grant_number="12345", + role="PI", + grant_pi_full_name="Stephanie Foster", + funding_agency=GrantFundingAgency.objects.get(name="Department of Defense (DoD)"), grant_start=start_date, grant_end=end_date, percent_credit=20.0, direct_funding=200000.0, total_amount_awarded=1000000.0, - status=GrantStatusChoice.objects.get(name='Active') + status=GrantStatusChoice.objects.get(name="Active"), ) # Add university cloud allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=end_date, is_changeable=True, - justification='Need to host my own site.' + justification="Need to host my own site.", ) - allocation_obj.resources.add( - Resource.objects.get(name='University Cloud')) + allocation_obj.resources.add(Resource.objects.get(name="University Cloud")) allocation_obj.save() - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Cloud Account Name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Cloud Account Name") AllocationAttribute.objects.get_or_create( allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, - value='sfoster-openstack') + value="sfoster-openstack", + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Core Usage (Hours)') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Core Usage (Hours)") allocation_attribute_obj, _ = AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value=1000) + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value=1000 + ) allocation_attribute_obj.allocationattributeusage.value = 200 allocation_attribute_obj.allocationattributeusage.save() - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi2, - status=AllocationUserStatusChoice.objects.get(name='Active') + AllocationUser.objects.create( + allocation=allocation_obj, user=pi2, status=AllocationUserStatusChoice.objects.get(name="Active") ) # Add university cloud storage allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=end_date, is_changeable=True, - justification='Need extra storage for webserver.' + justification="Need extra storage for webserver.", ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Cloud Account Name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Cloud Account Name") AllocationAttribute.objects.get_or_create( allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, - value='sfoster-openstack') + value="sfoster-openstack", + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Cloud Storage Quota (TB)') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Cloud Storage Quota (TB)") allocation_attribute_obj, _ = AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value=20) + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value=20 + ) allocation_attribute_obj.allocationattributeusage.value = 10 allocation_attribute_obj.allocationattributeusage.save() - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Cloud Account Name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Cloud Account Name") AllocationAttribute.objects.get_or_create( allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, - value='sfoster-openstack') + value="sfoster-openstack", + ) - allocation_obj.resources.add( - Resource.objects.get(name='University Cloud Storage')) + allocation_obj.resources.add(Resource.objects.get(name="University Cloud Storage")) allocation_obj.save() - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi2, - status=AllocationUserStatusChoice.objects.get(name='Active') + AllocationUser.objects.create( + allocation=allocation_obj, user=pi2, status=AllocationUserStatusChoice.objects.get(name="Active") ) # Set attributes for resources - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_default_value'), resource=Resource.objects.get(name='University Cloud Storage'), value=1) - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_default_value'), resource=Resource.objects.get(name='University Cloud'), value=1) - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_default_value'), resource=Resource.objects.get(name='ProjectStorage'), value=1) - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_default_value'), resource=Resource.objects.get(name='Budgetstorage'), value=10) - - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_label'), resource=Resource.objects.get(name='University Cloud Storage'), value='Enter storage in 1TB increments') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_label'), resource=Resource.objects.get(name='University Cloud'), value='Enter number of compute allocations to purchase') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_label'), resource=Resource.objects.get(name='ProjectStorage'), value='Enter storage in 1TB increments') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_label'), resource=Resource.objects.get(name='Budgetstorage'), value='Enter storage in 10TB increments (minimum purchase is 10TB)') - - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_cluster'), resource=Resource.objects.get(name='Chemistry'), value='chemistry') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_cluster'), resource=Resource.objects.get(name='Physics'), value='physics') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_cluster'), resource=Resource.objects.get(name='Industry'), value='industry') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_cluster'), resource=Resource.objects.get(name='University HPC'), value='university-hpc') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_cluster'), resource=Resource.objects.get(name='University Metered HPC'), - value='metered-hpc') - - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='Chemistry-scavenger'), value='QOS+=scavenger:Fairshare=100') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='Physics-scavenger'), value='QOS+=scavenger:Fairshare=100') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='Industry-scavenger'), value='QOS+=scavenger:Fairshare=100') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='Chemistry-cgray'), value='QOS+=cgray:Fairshare=100') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='Physics-sfoster'), value='QOS+=sfoster:Fairshare=100') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='University Metered HPC'), - value='GrpTRESMins=cpu={cpumin}') - - #slurm_specs_attrib_list for University Metered HPC - attriblist_list = [ '#Set cpumin from Core Usage attribute', - 'cpumin := :Core Usage (Hours)', - '#Default to 1 SU', - 'cpumin |= 1', - '#Convert to cpumin', - 'cpumin *= 60' + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_default_value"), + resource=Resource.objects.get(name="University Cloud Storage"), + value=1, + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_default_value"), + resource=Resource.objects.get(name="University Cloud"), + value=1, + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_default_value"), + resource=Resource.objects.get(name="ProjectStorage"), + value=1, + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_default_value"), + resource=Resource.objects.get(name="Budgetstorage"), + value=10, + ) + + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_label"), + resource=Resource.objects.get(name="University Cloud Storage"), + value="Enter storage in 1TB increments", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_label"), + resource=Resource.objects.get(name="University Cloud"), + value="Enter number of compute allocations to purchase", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_label"), + resource=Resource.objects.get(name="ProjectStorage"), + value="Enter storage in 1TB increments", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_label"), + resource=Resource.objects.get(name="Budgetstorage"), + value="Enter storage in 10TB increments (minimum purchase is 10TB)", + ) + + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_cluster"), + resource=Resource.objects.get(name="Chemistry"), + value="chemistry", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_cluster"), + resource=Resource.objects.get(name="Physics"), + value="physics", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_cluster"), + resource=Resource.objects.get(name="Industry"), + value="industry", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_cluster"), + resource=Resource.objects.get(name="University HPC"), + value="university-hpc", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_cluster"), + resource=Resource.objects.get(name="University Metered HPC"), + value="metered-hpc", + ) + + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="Chemistry-scavenger"), + value="QOS+=scavenger:Fairshare=100", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="Physics-scavenger"), + value="QOS+=scavenger:Fairshare=100", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="Industry-scavenger"), + value="QOS+=scavenger:Fairshare=100", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="Chemistry-cgray"), + value="QOS+=cgray:Fairshare=100", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="Physics-sfoster"), + value="QOS+=sfoster:Fairshare=100", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="University Metered HPC"), + value="GrpTRESMins=cpu={cpumin}", + ) + + # slurm_specs_attrib_list for University Metered HPC + attriblist_list = [ + "#Set cpumin from Core Usage attribute", + "cpumin := :Core Usage (Hours)", + "#Default to 1 SU", + "cpumin |= 1", + "#Convert to cpumin", + "cpumin *= 60", ] - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs_attriblist'), resource=Resource.objects.get(name='University Metered HPC'), - value="\n".join(attriblist_list)) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs_attriblist"), + resource=Resource.objects.get(name="University Metered HPC"), + value="\n".join(attriblist_list), + ) # call_command('loaddata', 'test_data.json') diff --git a/coldfront/core/utils/management/commands/show_users_in_project_but_not_in_allocation.py b/coldfront/core/utils/management/commands/show_users_in_project_but_not_in_allocation.py index 573a181f27..8ba531c67f 100644 --- a/coldfront/core/utils/management/commands/show_users_in_project_but_not_in_allocation.py +++ b/coldfront/core/utils/management/commands/show_users_in_project_but_not_in_allocation.py @@ -1,7 +1,8 @@ -import os +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later from django.conf import settings -from django.core.management import call_command from django.core.management.base import BaseCommand from coldfront.core.project.models import Project @@ -10,19 +11,19 @@ class Command(BaseCommand): - def handle(self, *args, **options): - for project in Project.objects.filter(status__name__in=['Active', 'New']): - users_in_project = list(project.projectuser_set.filter( - status__name='Active').values_list('user__username', flat=True)) + for project in Project.objects.filter(status__name__in=["Active", "New"]): + users_in_project = list( + project.projectuser_set.filter(status__name="Active").values_list("user__username", flat=True) + ) users_in_allocation = [] - for allocation in project.allocation_set.filter(status__name__in=('Active', - 'New', 'Paid', 'Payment Pending', - 'Payment Requested', 'Renewal Requested')): - - users_in_allocation.extend(allocation.allocationuser_set.filter( - status__name='Active').values_list('user__username', flat=True)) - - extra_users = list(set(users_in_project)-set(users_in_allocation)) + for allocation in project.allocation_set.filter( + status__name__in=("Active", "New", "Paid", "Payment Pending", "Payment Requested", "Renewal Requested") + ): + users_in_allocation.extend( + allocation.allocationuser_set.filter(status__name="Active").values_list("user__username", flat=True) + ) + + extra_users = list(set(users_in_project) - set(users_in_allocation)) if extra_users: print(project.id, project.title, project.pi, extra_users) diff --git a/coldfront/core/utils/migrations/__init__.py b/coldfront/core/utils/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/migrations/__init__.py +++ b/coldfront/core/utils/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/mixins/__init__.py b/coldfront/core/utils/mixins/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/mixins/__init__.py +++ b/coldfront/core/utils/mixins/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/mixins/views.py b/coldfront/core/utils/mixins/views.py index f9c5f6df6f..105749241c 100644 --- a/coldfront/core/utils/mixins/views.py +++ b/coldfront/core/utils/mixins/views.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import re from django.contrib import messages @@ -23,46 +27,47 @@ def to_snake(string): # it should work in the majority of cases, even allowing us to change app/class/etc. names # but cases like DOIDisplay (or similar, using multiple caps in a row) would fail - return string[0].lower() + re.sub('([A-Z])', r'_\1', string[1:]).lower() + return string[0].lower() + re.sub("([A-Z])", r"_\1", string[1:]).lower() app_label = self.model._meta.app_label model_name = self.model.__name__ - return ['{}/{}{}.html'.format(app_label, to_snake(model_name), self.template_name_suffix)] + return ["{}/{}{}.html".format(app_label, to_snake(model_name), self.template_name_suffix)] class ProjectInContextMixin: def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['project'] = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + context["project"] = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) return context class ChangesOnlyOnActiveProjectMixin: def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot modify an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot modify an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) class UserActiveManagerOrHigherMixin(LoginRequiredMixin, UserPassesTestMixin): def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True diff --git a/coldfront/core/utils/models.py b/coldfront/core/utils/models.py index 71a8362390..73294d7dba 100644 --- a/coldfront/core/utils/models.py +++ b/coldfront/core/utils/models.py @@ -1,3 +1,5 @@ -from django.db import models +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your models here. diff --git a/coldfront/core/utils/templatetags/__init__.py b/coldfront/core/utils/templatetags/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/templatetags/__init__.py +++ b/coldfront/core/utils/templatetags/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/templatetags/common_tags.py b/coldfront/core/utils/templatetags/common_tags.py index 2fc45b2d7c..6e8e1a738e 100644 --- a/coldfront/core/utils/templatetags/common_tags.py +++ b/coldfront/core/utils/templatetags/common_tags.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import template from django.conf import settings from django.utils.safestring import mark_safe @@ -9,51 +13,93 @@ @register.simple_tag def settings_value(name): allowed_names = [ - 'LOGIN_FAIL_MESSAGE', - 'ACCOUNT_CREATION_TEXT', - 'CENTER_NAME', - 'CENTER_HELP_URL', - 'EMAIL_PROJECT_REVIEW_CONTACT', + "LOGIN_FAIL_MESSAGE", + "ACCOUNT_CREATION_TEXT", + "CENTER_NAME", + "CENTER_HELP_URL", + "EMAIL_PROJECT_REVIEW_CONTACT", ] - return mark_safe(getattr(settings, name, '') if name in allowed_names else '') + # FIXME: This is using mark_safe for now but settings should not contain HTML in the future + return mark_safe(getattr(settings, name, "") if name in allowed_names else "") # noqa: S308 @register.filter def get_icon(expand_accordion): - if expand_accordion == 'show': - return 'fa-minus' + if expand_accordion == "show": + return "fa-minus" else: - return 'fa-plus' + return "fa-plus" @register.filter def convert_boolean_to_icon(boolean): - if boolean == False: - return mark_safe('') + if boolean is False: + return mark_safe('') else: - return mark_safe('') + return mark_safe('') @register.filter def convert_status_to_icon(project): - if project.last_project_review: - status = project.last_project_review.status.name - if status == 'Pending': - return mark_safe('

') - elif status == 'Completed': - return mark_safe('

') - elif project.needs_review and not project.last_project_review: - return mark_safe('

') - elif not project.needs_review: - return mark_safe('

') - - + last_project_review = project.last_project_review + needs_review = project.needs_review + if last_project_review: + status = last_project_review.status.name + if status == "Pending": + return mark_safe('

') + elif status == "Completed": + return mark_safe('

') + elif needs_review and not last_project_review: + return mark_safe('

') + elif not needs_review: + return mark_safe('

') -@register.filter('get_value_from_dict') +@register.filter("get_value_from_dict") def get_value_from_dict(dict_data, key): """ usage example {{ your_dict|get_value_from_dict:your_key }} """ if key: return dict_data.get(key) + + +@register.filter("get_value_by_index") +def get_value_by_index(array, index): + """ + usage example {{ your_list|get_value_by_index:your_index }} + """ + return array[index] + + +@register.simple_tag +def navbar_active_item(menu_item, request): + view_map = { + "center-summary": ["center-summary"], + "home": ["home"], + "invoice": ["allocation-invoice-list"], + "project": ["project-list", "allocation-list", "allocation-account-list", "resource-list"], + "admin": [ + "user-search-home", + "project-review-list", + "allocation-request-list", + "allocation-change-list", + "grant-report", + ], + "staff": [ + "user-search-home", + "project-review-list", + "allocation-request-list", + "grant-report", + ], + "director": [ + "project-review-list", + "grant-report", + ], + } + view_name = request.resolver_match.view_name + + if menu_item in view_map: + if view_name in view_map[menu_item]: + return "active" + return "" diff --git a/coldfront/core/utils/tests.py b/coldfront/core/utils/tests.py deleted file mode 100644 index 7ce503c2dd..0000000000 --- a/coldfront/core/utils/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/coldfront/core/utils/tests/__init__.py b/coldfront/core/utils/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/utils/tests/tests.py b/coldfront/core/utils/tests/tests.py new file mode 100644 index 0000000000..576ead011d --- /dev/null +++ b/coldfront/core/utils/tests/tests.py @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Create your tests here. diff --git a/coldfront/core/utils/validate.py b/coldfront/core/utils/validate.py index 5597a14086..e2760aac79 100644 --- a/coldfront/core/utils/validate.py +++ b/coldfront/core/utils/validate.py @@ -1,41 +1,49 @@ -import datetime -from django.core.exceptions import ValidationError -from django.core.validators import MinValueValidator -import formencode -from formencode import validators, Invalid - -class AttributeValidator: - - def __init__(self, value): - self.value = value - - def validate_int(self): - try: - validate = validators.Int() - validate.to_python(self.value) - except: - raise ValidationError( - f'Invalid Value {self.value}. Value must be an int.') - - def validate_float(self): - try: - validate = validators.Number() - validate.to_python(self.value) - except: - raise ValidationError( - f'Invalid Value {self.value}. Value must be an float.') - - def validate_yes_no(self): - try: - validate = validators.OneOf(['Yes','No']) - validate.to_python(self.value) - except: - raise ValidationError( - f'Invalid Value {self.value}. Value must be an Yes/No value.') - - def validate_date(self): - try: - datetime.datetime.strptime(self.value.strip(), "%Y-%m-%d") - except: - raise ValidationError( - f'Invalid Value {self.value}. Date must be in format YYYY-MM-DD and date must be today or later.') \ No newline at end of file +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import datetime + +from django.core.exceptions import ValidationError +from formencode import validators + + +class AttributeValidator: + def __init__(self, value): + self.value = value + + def _raise_if_empty(self): + if self.value == "": + raise ValidationError(f'Invalid Value "{self.value}". Value cannot be empty.') + + def validate_int(self): + self._raise_if_empty() + try: + validate = validators.Int() + validate.to_python(self.value) + except Exception: + raise ValidationError(f"Invalid Value {self.value}. Value must be an int.") + + def validate_float(self): + self._raise_if_empty() + try: + validate = validators.Number() + validate.to_python(self.value) + except Exception: + raise ValidationError(f"Invalid Value {self.value}. Value must be an float.") + + def validate_yes_no(self): + self._raise_if_empty() + try: + validate = validators.OneOf(["Yes", "No"]) + validate.to_python(self.value) + except Exception: + raise ValidationError(f"Invalid Value {self.value}. Value must be an Yes/No value.") + + def validate_date(self): + try: + datetime.datetime.strptime(self.value.strip(), "%Y-%m-%d") + except Exception: + raise ValidationError( + f"Invalid Value {self.value}. Date must be in format YYYY-MM-DD and date must be today or later." + ) diff --git a/coldfront/core/utils/views.py b/coldfront/core/utils/views.py index 91ea44a218..2fa8704650 100644 --- a/coldfront/core/utils/views.py +++ b/coldfront/core/utils/views.py @@ -1,3 +1,5 @@ -from django.shortcuts import render +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your views here. diff --git a/coldfront/plugins/__init__.py b/coldfront/plugins/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/__init__.py +++ b/coldfront/plugins/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/api/__init__.py b/coldfront/plugins/api/__init__.py new file mode 100644 index 0000000000..2f61f96d86 --- /dev/null +++ b/coldfront/plugins/api/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/api/apps.py b/coldfront/plugins/api/apps.py new file mode 100644 index 0000000000..3033b5d556 --- /dev/null +++ b/coldfront/plugins/api/apps.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import os + +from django.apps import AppConfig + +from coldfront.core.utils.common import import_from_settings + + +class ApiConfig(AppConfig): + name = "coldfront.plugins.api" + + def ready(self): + # Dynamically add the api plugin templates directory to TEMPLATES['DIRS'] + BASE_DIR = import_from_settings("BASE_DIR") + TEMPLATES = import_from_settings("TEMPLATES") + api_templates_dir = os.path.join(BASE_DIR, "coldfront/plugins/api/templates") + for template_setting in TEMPLATES: + if api_templates_dir not in template_setting["DIRS"]: + template_setting["DIRS"] = [api_templates_dir] + template_setting["DIRS"] diff --git a/coldfront/plugins/api/serializers.py b/coldfront/plugins/api/serializers.py new file mode 100644 index 0000000000..69ad36a63b --- /dev/null +++ b/coldfront/plugins/api/serializers.py @@ -0,0 +1,216 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from coldfront.core.allocation.models import Allocation, AllocationAttribute, AllocationChangeRequest, AllocationUser +from coldfront.core.project.models import Project, ProjectAttribute, ProjectUser +from coldfront.core.resource.models import Resource + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ( + "id", + "username", + "first_name", + "last_name", + "is_active", + "is_superuser", + "is_staff", + "date_joined", + ) + + +class ResourceSerializer(serializers.ModelSerializer): + resource_type = serializers.SlugRelatedField(slug_field="name", read_only=True) + + class Meta: + model = Resource + fields = ("id", "resource_type", "name", "description", "is_allocatable") + + +class AllocationSerializer(serializers.ModelSerializer): + resource = serializers.ReadOnlyField(source="get_resources_as_string") + project = serializers.SlugRelatedField(slug_field="title", read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) + allocation_users = serializers.SerializerMethodField() + allocation_attributes = serializers.SerializerMethodField() + + class Meta: + model = Allocation + fields = ( + "id", + "project", + "resource", + "status", + "allocation_users", + "allocation_attributes", + ) + + def get_allocation_users(self, obj): + request = self.context.get("request", None) + if request and request.query_params.get("allocation_users") in ["true", "True"]: + return AllocationUserSerializer(obj.allocationuser_set, many=True, read_only=True).data + return None + + def get_allocation_attributes(self, obj): + request = self.context.get("request", None) + if request and request.query_params.get("allocation_attributes") in ["true", "True"]: + return AllocationAttributeSerializer(obj.allocationattribute_set, many=True, read_only=True).data + return None + + +class AllocationUserSerializer(serializers.ModelSerializer): + user = serializers.SlugRelatedField(slug_field="username", read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) + + class Meta: + model = AllocationUser + fields = ("user", "status") + + +class AllocationAttributeSerializer(serializers.ModelSerializer): + allocation_attribute_type = serializers.SlugRelatedField(slug_field="name", read_only=True) + + class Meta: + model = AllocationAttribute + fields = ("allocation_attribute_type", "value") + + +class AllocationRequestSerializer(serializers.ModelSerializer): + project = serializers.SlugRelatedField(slug_field="title", read_only=True) + resource = serializers.ReadOnlyField(source="get_resources_as_string", read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) + fulfilled_date = serializers.DateTimeField(read_only=True) + created_by = serializers.SerializerMethodField(read_only=True) + fulfilled_by = serializers.SerializerMethodField(read_only=True) + time_to_fulfillment = serializers.DurationField(read_only=True) + + class Meta: + model = Allocation + fields = ( + "id", + "project", + "resource", + "status", + "created", + "created_by", + "fulfilled_date", + "fulfilled_by", + "time_to_fulfillment", + ) + + def get_created_by(self, obj): + historical_record = obj.history.earliest() + creator = historical_record.history_user if historical_record else None + if not creator: + return None + return historical_record.history_user.username + + def get_fulfilled_by(self, obj): + historical_records = obj.history.filter(status__name="Active") + if historical_records: + user = historical_records.earliest().history_user + if user: + return user.username + return None + + +class AllocationChangeRequestSerializer(serializers.ModelSerializer): + allocation = AllocationSerializer(read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) + created_by = serializers.SerializerMethodField(read_only=True) + fulfilled_date = serializers.DateTimeField(read_only=True) + fulfilled_by = serializers.SerializerMethodField(read_only=True) + time_to_fulfillment = serializers.DurationField(read_only=True) + + class Meta: + model = AllocationChangeRequest + fields = ( + "id", + "allocation", + "justification", + "status", + "created", + "created_by", + "fulfilled_date", + "fulfilled_by", + "time_to_fulfillment", + ) + + def get_created_by(self, obj): + historical_record = obj.history.earliest() + creator = historical_record.history_user if historical_record else None + if not creator: + return None + return historical_record.history_user.username + + def get_fulfilled_by(self, obj): + if not obj.status.name == "Approved": + return None + historical_record = obj.history.latest() + fulfiller = historical_record.history_user if historical_record else None + if not fulfiller: + return None + return historical_record.history_user.username + + +class ProjAllocationSerializer(serializers.ModelSerializer): + resource = serializers.ReadOnlyField(source="get_resources_as_string") + status = serializers.SlugRelatedField(slug_field="name", read_only=True) + + class Meta: + model = Allocation + fields = ("id", "resource", "status") + + +class ProjectUserSerializer(serializers.ModelSerializer): + user = serializers.SlugRelatedField(slug_field="username", read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) + role = serializers.SlugRelatedField(slug_field="name", read_only=True) + + class Meta: + model = ProjectUser + fields = ("user", "role", "status") + + +class ProjectAttributeSerializer(serializers.ModelSerializer): + proj_attr_type = serializers.SlugRelatedField(slug_field="name", read_only=True) + + class Meta: + model = ProjectAttribute + fields = ("proj_attr_type", "value") + + +class ProjectSerializer(serializers.ModelSerializer): + pi = serializers.SlugRelatedField(slug_field="username", read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) + project_users = serializers.SerializerMethodField() + allocations = serializers.SerializerMethodField() + project_attributes = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = ("id", "title", "pi", "status", "project_users", "allocations", "project_attributes") + + def get_project_users(self, obj): + request = self.context.get("request", None) + if request and request.query_params.get("project_users") in ["true", "True"]: + return ProjectUserSerializer(obj.projectuser_set, many=True, read_only=True).data + return None + + def get_allocations(self, obj): + request = self.context.get("request", None) + if request and request.query_params.get("allocations") in ["true", "True"]: + return ProjAllocationSerializer(obj.allocation_set, many=True, read_only=True).data + return None + + def get_project_attributes(self, obj): + request = self.context.get("request", None) + if request and request.query_params.get("project_attributes") in ["true", "True"]: + return ProjectAttributeSerializer(obj.projectattribute_set, many=True, read_only=True).data + return None diff --git a/coldfront/plugins/api/templates/user/user_profile.html b/coldfront/plugins/api/templates/user/user_profile.html new file mode 100644 index 0000000000..deab5f8c6f --- /dev/null +++ b/coldfront/plugins/api/templates/user/user_profile.html @@ -0,0 +1,107 @@ +{% extends "user/user_profile.html" %} +{% block profile_contents %} + {% csrf_token %} + {{ block.super }} + {% if viewed_user == request.user %} + + API Token: + +
+ {% if request.user.auth_token.key %} + + •••••••{{ request.user.auth_token.key|slice:"-6:" }} + + {% else %} + None + {% endif %} +
+
+ + + + + {% endif %} + +{% endblock %} diff --git a/coldfront/plugins/api/tests.py b/coldfront/plugins/api/tests.py new file mode 100644 index 0000000000..bcde02d1eb --- /dev/null +++ b/coldfront/plugins/api/tests.py @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import unittest + +from rest_framework import status +from rest_framework.test import APITestCase + +from coldfront.config.env import ENV +from coldfront.core.allocation.models import Allocation +from coldfront.core.project.models import Project +from coldfront.core.test_helpers.factories import ( + AllocationAttributeFactory, + AllocationFactory, + AllocationUserFactory, + PAttributeTypeFactory, + ProjectAttributeFactory, + ProjectAttributeTypeFactory, + ProjectFactory, + ProjectStatusChoiceFactory, + ProjectUserFactory, + ResourceFactory, + UserFactory, +) + + +@unittest.skipUnless(ENV.bool("PLUGIN_API", default=False), "Only run API tests if enabled") +class ColdfrontAPI(APITestCase): + """Tests for the Coldfront REST API""" + + @classmethod + def setUpTestData(self): + """Test Data setup for ColdFront REST API tests.""" + self.admin_user = UserFactory(is_staff=True, is_superuser=True) + pat = ProjectAttributeTypeFactory(attribute_type=PAttributeTypeFactory(name="Text")) + + for i in range(10): + project = ProjectFactory(status=ProjectStatusChoiceFactory(name="Active")) + ProjectUserFactory(project=project, user=self.admin_user) + ProjectAttributeFactory(project=project, proj_attr_type=pat) + + allocation = AllocationFactory(project=project) + allocation.resources.add(ResourceFactory(name="test")) + AllocationUserFactory(allocation=allocation, user=self.admin_user) + AllocationAttributeFactory(allocation=allocation) + self.pi_user = project.pi + + def test_requires_login(self): + """Test that the API requires authentication""" + response = self.client.get("/api/") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_allocation_request_api_permissions(self): + """Test that accessing the allocation-request API view as an admin returns all + allocations, and that accessing it as a user is forbidden""" + # login as admin + self.client.force_login(self.admin_user) + response = self.client.get("/api/allocation-requests/", format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.client.force_login(self.pi_user) + response = self.client.get("/api/allocation-requests/", format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_allocation_api_permissions(self): + """Test that accessing the allocation API view as an admin returns all + allocations, and that accessing it as a user returns only the allocations + for that user""" + # login as admin + self.client.force_login(self.admin_user) + response = self.client.get("/api/allocations/", format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), Allocation.objects.all().count()) + + self.client.force_login(self.pi_user) + response = self.client.get("/api/allocations/", format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_allocation_query_params(self): + """Test that specifying the query parameters returns the related + information""" + # login as admin + self.client.force_login(self.admin_user) + response = self.client.get("/api/allocations/?allocation_users=true", format="json") + for alloc in response.json(): + self.assertEqual(len(alloc["allocation_users"]), 1) + self.assertIsNone(alloc["allocation_attributes"]) + + response = self.client.get("/api/allocations/?allocation_attributes=true", format="json") + for alloc in response.json(): + self.assertIsNone(alloc["allocation_users"]) + self.assertEqual(len(alloc["allocation_attributes"]), 1) + + response = self.client.get("/api/allocations/?allocation_users=true&allocation_attributes=true", format="json") + for alloc in response.json(): + self.assertEqual(len(alloc["allocation_users"]), 1) + self.assertEqual(len(alloc["allocation_attributes"]), 1) + + def test_project_api_permissions(self): + """Confirm permissions for project API: + admin user should be able to access everything + Projectusers should be able to access only their projects + """ + # login as admin + self.client.force_login(self.admin_user) + response = self.client.get("/api/projects/", format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), Project.objects.all().count()) + + self.client.force_login(self.pi_user) + response = self.client.get("/api/projects/", format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_project_query_params(self): + """Test that specifying the query parameters returns the related + information""" + # login as admin + self.client.force_login(self.admin_user) + response = self.client.get("/api/projects/?project_users=true", format="json") + for proj in response.json(): + self.assertEqual(len(proj["project_users"]), 1) + + response = self.client.get("/api/projects/?project_attributes=true", format="json") + for proj in response.json(): + self.assertEqual(len(proj["project_attributes"]), 1) + + response = self.client.get("/api/projects/?allocations=true", format="json") + for proj in response.json(): + self.assertEqual(len(proj["allocations"]), 1) + + def test_user_api_permissions(self): + """Test that accessing the user API view as an admin returns all + allocations, and that accessing it as a user is forbidden""" + # login as admin + self.client.force_login(self.admin_user) + response = self.client.get("/api/users/", format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.client.force_login(self.pi_user) + response = self.client.get("/api/users/", format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/coldfront/plugins/api/urls.py b/coldfront/plugins/api/urls.py new file mode 100644 index 0000000000..3d14e7fbdc --- /dev/null +++ b/coldfront/plugins/api/urls.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.urls import include, path +from rest_framework import routers + +from coldfront.plugins.api import views + +router = routers.DefaultRouter() +router.register(r"allocations", views.AllocationViewSet, basename="allocations") +router.register(r"allocation-requests", views.AllocationRequestViewSet, basename="allocation-requests") +router.register( + r"allocation-change-requests", views.AllocationChangeRequestViewSet, basename="allocation-change-requests" +) +router.register(r"projects", views.ProjectViewSet, basename="projects") +router.register(r"resources", views.ResourceViewSet, basename="resources") +router.register(r"users", views.UserViewSet, basename="users") + +urlpatterns = [ + path("", include(router.urls)), + path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), + path("regenerate-token/", views.regenerate_token, name="regenerate_token"), +] diff --git a/coldfront/plugins/api/views.py b/coldfront/plugins/api/views.py new file mode 100644 index 0000000000..f18f3b73aa --- /dev/null +++ b/coldfront/plugins/api/views.py @@ -0,0 +1,353 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.db.models import ExpressionWrapper, F, OuterRef, Q, Subquery, fields +from django.db.models.functions import Cast +from django_filters import rest_framework as filters +from rest_framework import viewsets +from rest_framework.authtoken.models import Token +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.response import Response +from simple_history.utils import get_history_model_for_model + +from coldfront.core.allocation.models import Allocation, AllocationChangeRequest +from coldfront.core.project.models import Project +from coldfront.core.resource.models import Resource +from coldfront.plugins.api import serializers + +logger = logging.getLogger(__name__) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def regenerate_token(request): + old_token = None + if hasattr(request.user, "auth_token"): + old_token = request.user.auth_token.key[-6:] # Last 6 chars for logging + # Delete existing token + Token.objects.filter(user=request.user).delete() + + # Create new token + token = Token.objects.create(user=request.user) + + logger.info( + "API token regenerated for user %s (uid: %s). Old token ending: %s", + request.user.username, + request.user.id, + old_token or "None", + ) + return Response({"token": token.key}) + + +class ResourceViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = serializers.ResourceSerializer + queryset = Resource.objects.all() + + +class AllocationViewSet(viewsets.ReadOnlyModelViewSet): + """ + Query parameters: + - allocation_users (default false) + Show related user data. + - allocation_attributes (default false) + Show related attribute data. + """ + + serializer_class = serializers.AllocationSerializer + # permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + + def get_queryset(self): + allocations = Allocation.objects.prefetch_related("project", "project__pi", "status") + + if not (self.request.user.is_superuser or self.request.user.has_perm("allocation.can_view_all_allocations")): + allocations = allocations.filter( + Q(project__status__name__in=["New", "Active"]) + & ( + ( + Q(project__projectuser__role__name__contains="Manager") + & Q(project__projectuser__user=self.request.user) + ) + | Q(project__pi=self.request.user) + ) + ).distinct() + + allocations = allocations.order_by("project") + + if self.request.query_params.get("allocation_users") in ["True", "true"]: + allocations = allocations.prefetch_related("allocationuser_set") + + if self.request.query_params.get("allocation_attributes") in ["True", "true"]: + allocations = allocations.prefetch_related("allocationattribute_set") + + return allocations + + +class AllocationRequestFilter(filters.FilterSet): + """Filters for AllocationChangeRequestViewSet. + created_before is the date the request was created before. + created_after is the date the request was created after. + """ + + created = filters.DateFromToRangeFilter() + fulfilled = filters.BooleanFilter(method="filter_fulfilled", label="Fulfilled") + fulfilled_date = filters.DateFromToRangeFilter(label="Date fulfilled") + time_to_fulfillment = filters.NumericRangeFilter(method="filter_time_to_fulfillment", label="Time to fulfillment") + + class Meta: + model = Allocation + fields = [ + "created", + "fulfilled", + "fulfilled_date", + "time_to_fulfillment", + ] + + def filter_fulfilled(self, queryset, name, value): + if value: + return queryset.filter(fulfilled_date__isnull=False) + else: + return queryset.filter(fulfilled_date__isnull=True) + + def filter_time_to_fulfillment(self, queryset, name, value): + if value.start is not None: + queryset = queryset.filter(time_to_fulfillment__gte=timedelta(days=int(value.start))) + if value.stop is not None: + queryset = queryset.filter(time_to_fulfillment__lte=timedelta(days=int(value.stop))) + return queryset + + +class AllocationRequestViewSet(viewsets.ReadOnlyModelViewSet): + """Report view on allocations requested through Coldfront. + Data: + - id: allocation id + - project: project name + - resource: resource name + - status: current status of the allocation + - created: date created + - created_by: user who submitted the allocation request + - fulfilled_date: date the allocation's status was first set to "Active" + - fulfilled_by: user who first set the allocation status to "Active" + - time_to_fulfillment: time between request creation and time_to_fulfillment + displayed as "DAY_INTEGER HH:MM:SS" + + Filters: + - created_before/created_after (structure date as 'YYYY-MM-DD') + - fulfilled (boolean) + Set to true to return all approved requests, false to return all pending and denied requests. + - fulfilled_date_before/fulfilled_date_after (structure date as 'YYYY-MM-DD') + - time_to_fulfillment_max/time_to_fulfillment_min (integer) + Set to the maximum/minimum number of days between request creation and time_to_fulfillment. + """ + + serializer_class = serializers.AllocationRequestSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = AllocationRequestFilter + permission_classes = [IsAuthenticated, IsAdminUser] + + def get_queryset(self): + HistoricalAllocation = get_history_model_for_model(Allocation) + + # Subquery to get the earliest historical record for each allocation + earliest_history = ( + HistoricalAllocation.objects.filter(id=OuterRef("pk")).order_by("history_date").values("status__name")[:1] + ) + + fulfilled_date = ( + HistoricalAllocation.objects.filter(id=OuterRef("pk"), status__name="Active") + .order_by("history_date") + .values("modified")[:1] + ) + + # Annotate allocations with the status_id of their earliest historical record + allocations = ( + Allocation.objects.annotate(earliest_status_name=Subquery(earliest_history)) + .filter(earliest_status_name="New") + .order_by("created") + ) + + allocations = allocations.annotate(fulfilled_date=Subquery(fulfilled_date)) + + allocations = allocations.annotate( + time_to_fulfillment=ExpressionWrapper( + (Cast(Subquery(fulfilled_date), fields.DateTimeField()) - F("created")), + output_field=fields.DurationField(), + ) + ) + return allocations + + +class AllocationChangeRequestFilter(filters.FilterSet): + """Filters for AllocationChangeRequestViewSet. + created_before is the date the request was created before. + created_after is the date the request was created after. + """ + + created = filters.DateFromToRangeFilter() + fulfilled = filters.BooleanFilter(method="filter_fulfilled", label="Fulfilled") + fulfilled_date = filters.DateFromToRangeFilter(label="Date fulfilled") + time_to_fulfillment = filters.NumericRangeFilter(method="filter_time_to_fulfillment", label="Time to fulfillment") + + class Meta: + model = AllocationChangeRequest + fields = [ + "created", + "fulfilled", + "fulfilled_date", + "time_to_fulfillment", + ] + + def filter_fulfilled(self, queryset, name, value): + if value: + return queryset.filter(status__name="Approved") + else: + return queryset.filter(status__name__in=["Pending", "Denied"]) + + def filter_time_to_fulfillment(self, queryset, name, value): + if value.start is not None: + queryset = queryset.filter(time_to_fulfillment__gte=timedelta(days=int(value.start))) + if value.stop is not None: + queryset = queryset.filter(time_to_fulfillment__lte=timedelta(days=int(value.stop))) + return queryset + + +class AllocationChangeRequestViewSet(viewsets.ReadOnlyModelViewSet): + """ + Data: + - allocation: allocation object details + - justification: justification provided at time of filing + - status: request status + - created: date created + - created_by: user who created the object. + - fulfilled_date: date the allocationchangerequests's status was first set to "Approved" + - fulfilled_by: user who last modified an approved object. + + Query parameters: + - created_before/created_after (structure date as 'YYYY-MM-DD') + - fulfilled (boolean) + Set to true to return all approved requests, false to return all pending and denied requests. + - fulfilled_date_before/fulfilled_date_after (structure date as 'YYYY-MM-DD') + - time_to_fulfillment_max/time_to_fulfillment_min (integer) + Set to the maximum/minimum number of days between request creation and time_to_fulfillment. + """ + + serializer_class = serializers.AllocationChangeRequestSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = AllocationChangeRequestFilter + + def get_queryset(self): + requests = AllocationChangeRequest.objects.prefetch_related( + "allocation", "allocation__project", "allocation__project__pi" + ) + + if not (self.request.user.is_superuser or self.request.user.is_staff): + requests = requests.filter( + Q(allocation__project__status__name__in=["New", "Active"]) + & ( + ( + Q(allocation__project__projectuser__role__name__contains="Manager") + & Q(allocation__project__projectuser__user=self.request.user) + ) + | Q(allocation__project__pi=self.request.user) + ) + ).distinct() + + HistoricalAllocationChangeRequest = get_history_model_for_model(AllocationChangeRequest) + + fulfilled_date = ( + HistoricalAllocationChangeRequest.objects.filter(id=OuterRef("pk"), status__name="Approved") + .order_by("history_date") + .values("modified")[:1] + ) + + requests = requests.annotate(fulfilled_date=Subquery(fulfilled_date)) + + requests = requests.annotate( + time_to_fulfillment=ExpressionWrapper( + (Cast(Subquery(fulfilled_date), fields.DateTimeField()) - F("created")), + output_field=fields.DurationField(), + ) + ) + requests = requests.order_by("created") + + return requests + + +class ProjectViewSet(viewsets.ReadOnlyModelViewSet): + """ + Query parameters: + - allocations (default false) + Show related allocation data. + - project_users (default false) + Show related user data. + - project_attributes (default false) + Show related attribute data. + """ + + serializer_class = serializers.ProjectSerializer + + def get_queryset(self): + projects = Project.objects.prefetch_related("status") + + if not ( + self.request.user.is_superuser + or self.request.user.is_staff + or self.request.user.has_perm("project.can_view_all_projects") + ): + projects = ( + projects.filter( + Q(status__name__in=["New", "Active"]) + & ( + (Q(projectuser__role__name__contains="Manager") & Q(projectuser__user=self.request.user)) + | Q(pi=self.request.user) + ) + ) + .distinct() + .order_by("pi") + ) + + if self.request.query_params.get("project_users") in ["True", "true"]: + projects = projects.prefetch_related("projectuser_set") + + if self.request.query_params.get("allocations") in ["True", "true"]: + projects = projects.prefetch_related("allocation_set") + + if self.request.query_params.get("project_attributes") in ["True", "true"]: + projects = projects.prefetch_related("projectattribute_set") + + return projects.order_by("pi") + + +class UserFilter(filters.FilterSet): + is_staff = filters.BooleanFilter() + is_active = filters.BooleanFilter() + is_superuser = filters.BooleanFilter() + username = filters.CharFilter(field_name="username", lookup_expr="exact") + + class Meta: + model = get_user_model() + fields = ["is_staff", "is_active", "is_superuser", "username"] + + +class UserViewSet(viewsets.ReadOnlyModelViewSet): + """Staff and superuser-only view for user data. + Filter parameters: + - username (exact) + - is_active + - is_superuser + - is_staff + """ + + serializer_class = serializers.UserSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = UserFilter + permission_classes = [IsAuthenticated, IsAdminUser] + + def get_queryset(self): + queryset = get_user_model().objects.all() + return queryset diff --git a/coldfront/plugins/auto_compute_allocation/README.md b/coldfront/plugins/auto_compute_allocation/README.md new file mode 100644 index 0000000000..d22c44b154 --- /dev/null +++ b/coldfront/plugins/auto_compute_allocation/README.md @@ -0,0 +1,168 @@ +# auto\_compute\_allocation - A plugin to create an automatically assigned compute allocation + +Coldfront django plugin providing capability to create an automatically assigned compute allocation (a Coldfront project resource allocation mapping to an HPC Cluster resource or multiple such resources). + +The motivation for using this plugin is to use Coldfront as the source of truth. This might be in contrast to another operating modality where information is [generally] imported from another system into Coldfront to provide allocations. + +- By using the plugin an allocation to use configured HPC Cluster(s) will be created each time a new project is created, which Coldfront operators simply need to check over and then approve/activate... + +This has the benefit of reducing workload: +- Coldfront operators workload is reduced slightly, whilst also providing consistency and accuracy - operators are required to input and do less. +- Another reason might be to reduce PI workload. As on a free-at-the-point of use system, its likely that all projects simply get granted a compute allocation and therefore a slurm association to be able to use the HPC Cluster(s). The PI will automatically have the allocation created by Coldfront itself. + + +## Design + + +This plugin makes use of a django signal within Coldfront's project view in order to trigger creation. Naming of the allocation created uses ``project_code``. + +Allocations are named in the format _auto|Cluster|project_code_ (e.g. ``auto|Cluster|CDF0001``). This allocation description and it's delimiters within, can be controlled with the variable: ``AUTO_COMPUTE_ALLOCATION_DESCRIPTION``, though the _project_code_ will always be appended. + +At the time of project creation, only the PI can be known, so this is the only user added. + +Further down in the documentation, all variables are described in a table. + +As well as controlling the end date of the generated allocation, the changeable and locked attributes can be toggled. + +Optionally **gauges** for accelerator and core hours can be assigned to new projects by providing an integer greater than 0. + +- ``AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS`` +- ``AUTO_COMPUTE_ALLOCATION_CORE_HOURS`` + +...specifically for training (field of science = training) projects with: + +- ``AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING`` +- ``AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING`` + + +A variable can be used to filter which Cluster resources the allocation can work with ``AUTO_COMPUTE_ALLOCATION_CLUSTERS``. + +An optional usage, is to enable an **institution based fairshare attribute** - ``AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION``. This requires the _institution feature_ has been enabled correctly, such that a match is found (for the submitting PI). If a match isn't found then this attribute can't be set and the code handles. + +The **slurm account can be named** and its naming controlled via ``AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT``. Similarly ``AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT`` gives some control over the output of the **institutional fairshare naming/value**. + +**slurm_attributes can be added** with ``AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE`` and ``AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING``. + + +### Design - signals and actions + +#### signals + +The following Coldfront django signal is used by this plugin, upon Coldfront WebUI action: + +- project new + +#### actions + +The aforementioned signal triggers a function in ``tasks.py``, this in turn uses functions in ``utils.py`` to accomplish the action required which is to create an automatically generated compute allocation for the project. + +## Management commands + +No management commands are present or required by this plugin itself. + + +## Requirements + +The plugin requires that the project code feature is enabled. + +``PROJECT_CODE`` is required to be set to a valid string. E.g. 'CDF', 'COMP' etc. in the Coldfront django settings - e.g. coldfront.env + + +## Usage + +The plugin requires that various environment variables are defined in the Coldfront django settings - e.g. coldfront.env + +Example pre-requisites - we require ``PROJECT_CODE`` to be enabled, here is an example using CDF and padding of 4. The padding is optional but ``PROJECT_CODE`` is required. + +**Example Required project_code:** +``` +PROJECT_CODE="CDF" +``` +Example Optional project_code padding: +``` +PROJECT_CODE_PADDING=4 +``` + + +**Required Plugin load:** + +| Option | Type | Default | Description | +|--- | --- | --- | --- | +| `PLUGIN_AUTO_COMPUTE_ALLOCATION` | Bool | False, not defined | Enable the plugin, required to be set as True (bool). | + + +Next the environment variables for the plugin itself, here are the descriptions and defaults. + +### Auto_Compute_Allocation Plugin optional variables + +All variables for this plugin are currently **optional**. + +| Option | Type | Default | Description | +|--- | --- | --- | --- | +| `AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS` | int | 0 | Optional, **not a slurm attribute, enables guage**, number of accelerator hours to provide on the allocation, if 0 then this functionality is not triggered and no accelerator hours will be added | +| `AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING` | int | 0 | Optional, **not a slurm attribute, enables guage**, number of accelerator hours to provide on the allocation, if 0 then this functionality is not triggered and no accelerator hours will be added. This applies to projects which select 'Training' as their field of science discipline. | +| `AUTO_COMPUTE_ALLOCATION_CORE_HOURS` | int | 0 | Optional, **not a slurm attribute, enables guage**, number of core hours to provide on the allocation, if 0 then this functionality is not triggered and no core hours will be added | +| `AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING` | int | 0 | Optional, **not a slurm attribute, enables guage**, number of core hours to provide on the allocation, if 0 then this functionality is not triggered and no core hours will be added. This applies to projects which select 'Training' as their field of science discipline. | +| `AUTO_COMPUTE_ALLOCATION_END_DELTA` | int | 365 | Optional, number of days from creation of the allocation to expiry, default 365 to align with default project duration of 1 year | +| `AUTO_COMPUTE_ALLOCATION_CHANGEABLE` | bool | True | Optional, allows the allocation to have a request logged to change - this might be useful for an extension | +| `AUTO_COMPUTE_ALLOCATION_LOCKED` | bool | False | Optional, prevents the allocation from being modified by admin - this might be useful for an extensions | +| `AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION` | bool | False | Optional, provides an institution based slurm fairshare attribute, requires that the _institution feature_ is setup correctly | +| `AUTO_COMPUTE_ALLOCATION_CLUSTERS` | tuple | empty () | Optional, filter for clusters to automatically allocate on - example value ``AUTO_COMPUTE_ALLOCATION_CLUSTERS=(Cluster1,Cluster4)`` | +| `AUTO_COMPUTE_ALLOCATION_DESCRIPTION` | str | "auto\|Cluster\|" | Optionally control the produced description for the allocation and its delimiters within. The _project_code_ will always be appended. Example resultant description: ``auto\|Cluster\|CDF0001`` | +| `AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE` | tuple | empty () | Optional, a tuple of slurm_attributes to add to the allocation. **Note each element needs an internal delimiter of a semi-colon `;` rather than a comma, if a comma is present in your intended element string**.

An example is `AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE=('GrpTRESMins=cpu=999;mem=999;gres/gpu=999',)` which defines a single element tuple and therefore 1x slurm attribute. More could be added. Note the internal semi-colon `;` delimiter instead of a comma with the string. | +| `AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING` | tuple | empty () | Optional, a tuple of slurm_attributes to add to the allocation for a training project. **Note each element needs an internal delimiter of a semi-colon `;` rather than a comma, if a comma is present in your intended element string**.

An example is `AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING=('GrpTRESMins=cpu=999;mem=999;gres/gpu=999',)` which defines a single element tuple and therefore 1x slurm attribute. More could be added. Note the internal semi-colon `;` delimiter instead of a comma with the string. | +| `AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT` | str | empty "" | Optional, variable to define how the fairshare attribute will be named.

**If not defined then the default format `{institution}` will be used**.| +| `AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT` | str | empty "" | Optional, variable to define how the slurm_account_name attribute will be named.

**If not defined then the default format `{project_code}_{PI_First_Initial}_{PI_Last_Name_Formatted}_{allocation_id}` will be used**.

If you just want `project_code` then use `AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT="{project_code}"`.| + + +#### AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT - detail + +This table shows the possible values that can be used for the ENV var ``AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT`` string. + +| AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT | value | comment | +|--- | --- | --- | +| | `allocation_id` | the allocation id - useful as will be a distinct number (pk) - this will not necessarily be in sequence but is unique | +| | `institution_abbr_upper_lower` | the institution's capital letters extracted and joined as lowercase - to make an abbreviation | +| | `institution_abbr_upper_upper` | the institution's capital letters extracted and joined as uppercase - to make an abbreviation | +| | `institution` | institution with spaces converted to '_' | +| | `institution_formatted` | institution lowercase with spaces converted to '_' | +| | `PI_first_initial` | PI last name initial lowercase| +| | `PI_first_name` | PI first name lowercase | +| | `PI_last_initial` | PI first initial lowercase | +| | `PI_last_name_formatted` | PI last name lowercase with spaces converted to '_' | +| | `PI_last_name` | PI last name lowercase | +| | `project_code` | the project_code | +| | `project_id` | the project_id (pk) wont be distinct within multiple allocations in the same project | + + +#### AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT - detail + +This table shows the possible values that can be used for the ENV var ``AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT`` string. + +| AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT | value | comment | +|--- | --- | --- | +| | `institution_abbr_upper_lower` | the institution's capital letters extracted and joined as lowercase - to make an abbreviation | +| | `institution_abbr_upper_upper` | the institution's capital letters extracted and joined as uppercase - to make an abbreviation | +| | `institution` | institution with spaces converted to '_' | +| | `institution_formatted` | institution lowercase with spaces converted to '_' | + + + +## Example settings + +- show a gauge for 10k core hours for new project +- show a gauge for 100 core hours for a new training project +- default 365 end delta - no need to set variable + +``` +AUTO_COMPUTE_ALLOCATION_CORE_HOURS=10000 +AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING=100 +``` + + +## Future work + +Future work could include: + +- slurm parent accounts +- a seperate plugin for storage allocations - ``auto_storage_allocation`` diff --git a/coldfront/plugins/auto_compute_allocation/__init__.py b/coldfront/plugins/auto_compute_allocation/__init__.py new file mode 100644 index 0000000000..6d24412f63 --- /dev/null +++ b/coldfront/plugins/auto_compute_allocation/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + diff --git a/coldfront/plugins/auto_compute_allocation/apps.py b/coldfront/plugins/auto_compute_allocation/apps.py new file mode 100644 index 0000000000..9581bad84a --- /dev/null +++ b/coldfront/plugins/auto_compute_allocation/apps.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Coldfront auto_compute_allocation plugin apps.py""" + +import importlib + +from django.apps import AppConfig + + +class AutoComputeAllocationConfig(AppConfig): + name = "coldfront.plugins.auto_compute_allocation" + + def ready(self): + importlib.import_module("coldfront.plugins.auto_compute_allocation.signals") diff --git a/coldfront/plugins/auto_compute_allocation/fairshare_institution_name.py b/coldfront/plugins/auto_compute_allocation/fairshare_institution_name.py new file mode 100644 index 0000000000..899e752d90 --- /dev/null +++ b/coldfront/plugins/auto_compute_allocation/fairshare_institution_name.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Coldfront auto_compute_allocation plugin fairshare_institution_name.py""" + +import logging + +from coldfront.core.utils.common import import_from_settings + +AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT = import_from_settings( + "AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT" +) + +logger = logging.getLogger(__name__) + + +def generate_fairshare_institution_name(project_obj): + """Method to generate a fairshare_institution_name using predefined variables""" + + # Get the uppercase characters from institution + institution_abbr_raw = "".join([c for c in project_obj.institution if c.isupper()]) + + FAIRSHARE_INSTITUTION_NAME_VARS = { + "institution_abbr_upper_lower": institution_abbr_raw.lower(), + "institution_abbr_upper_upper": institution_abbr_raw, + "institution": project_obj.institution.replace(" ", ""), + "institution_formatted": project_obj.institution.replace(" ", "_").lower(), + } + + # if a faishare institution name format is defined as a string (use env var), else use format suggested + if AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT: + gen_fairshare_institution_name = AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT.format( + **FAIRSHARE_INSTITUTION_NAME_VARS + ) + else: + gen_fairshare_institution_name = "{institution}".format(**FAIRSHARE_INSTITUTION_NAME_VARS) + + logger.info(f"Generated fairshare institution name {gen_fairshare_institution_name}") + + return gen_fairshare_institution_name diff --git a/coldfront/plugins/auto_compute_allocation/signals.py b/coldfront/plugins/auto_compute_allocation/signals.py new file mode 100644 index 0000000000..3d7be6e301 --- /dev/null +++ b/coldfront/plugins/auto_compute_allocation/signals.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Coldfront auto_compute_allocation plugin signals.py""" + +import logging + +from django.dispatch import receiver +from django_q.tasks import async_task + +from coldfront.core.project.signals import project_new +from coldfront.core.project.views import ProjectCreateView + +logger = logging.getLogger(__name__) + + +@receiver(project_new, sender=ProjectCreateView) +def project_new_auto_compute_allocation(sender, **kwargs): + project_obj = kwargs.get("project_obj") + # Add a compute allocation + async_task( + "coldfront.plugins.auto_compute_allocation.tasks.add_auto_compute_allocation", + project_obj, + ) diff --git a/coldfront/plugins/auto_compute_allocation/slurm_account_name.py b/coldfront/plugins/auto_compute_allocation/slurm_account_name.py new file mode 100644 index 0000000000..b5cf0c93f3 --- /dev/null +++ b/coldfront/plugins/auto_compute_allocation/slurm_account_name.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Coldfront auto_compute_allocation plugin slurm_account_name.py""" + +import logging + +from coldfront.core.utils.common import import_from_settings + +AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT = import_from_settings( + "AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT" +) + +logger = logging.getLogger(__name__) + + +def generate_slurm_account_name(allocation_obj, project_obj): + """Method to generate a slurm_account_name using predefined variables""" + + # define valid vars for naming slurm account in dictionary + SLURM_ACCOUNT_NAME_VARS = { + "allocation_id": allocation_obj.pk, + "PI_first_initial": project_obj.pi.first_name[0].lower(), + "PI_first_name": project_obj.pi.first_name.lower(), + "PI_last_initial": project_obj.pi.last_name[0].lower(), + "PI_last_name_formatted": project_obj.pi.last_name.replace(" ", "_").lower(), + "PI_last_name": project_obj.pi.last_name.lower(), + "project_code": project_obj.project_code, + "project_id": project_obj.pk, + } + + if hasattr(project_obj, "institution"): + # Get the uppercase characters from institution + institution_abbr_raw = "".join([c for c in project_obj.institution if c.isupper()]) + + SLURM_ACCOUNT_NAME_VARS.update( + { + "institution_abbr_upper_lower": institution_abbr_raw.lower(), + "institution_abbr_upper_upper": institution_abbr_raw, + "institution": project_obj.institution.replace(" ", "_"), + "institution_formatted": project_obj.institution.replace(" ", "_").lower(), + } + ) + + # if a slurm account name format is defined as a string (use env var), else use format suggested + if AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT: + gen_slurm_account_name = AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT.format(**SLURM_ACCOUNT_NAME_VARS) + else: + gen_slurm_account_name = "{project_code}_{PI_first_initial}_{PI_last_name_formatted}_{allocation_id}".format( + **SLURM_ACCOUNT_NAME_VARS + ) + + logger.info(f"Generated slurm account name {gen_slurm_account_name}") + + return gen_slurm_account_name diff --git a/coldfront/plugins/auto_compute_allocation/tasks.py b/coldfront/plugins/auto_compute_allocation/tasks.py new file mode 100644 index 0000000000..f79340b11b --- /dev/null +++ b/coldfront/plugins/auto_compute_allocation/tasks.py @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Coldfront auto_compute_allocation plugin tasks.py""" + +import logging + +from coldfront.core.allocation.models import AllocationAttributeType +from coldfront.core.utils.common import import_from_settings +from coldfront.plugins.auto_compute_allocation.slurm_account_name import generate_slurm_account_name +from coldfront.plugins.auto_compute_allocation.utils import ( + allocation_auto_compute, + allocation_auto_compute_attribute_create, + allocation_auto_compute_fairshare_institution, + allocation_auto_compute_pi, + get_cluster_resources_tuple, +) + +logger = logging.getLogger(__name__) + +# Environment variables for auto_compute_allocation in tasks.py +AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS = import_from_settings("AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS") +AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING = import_from_settings( + "AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING" +) +AUTO_COMPUTE_ALLOCATION_CORE_HOURS = import_from_settings("AUTO_COMPUTE_ALLOCATION_CORE_HOURS") +AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING = import_from_settings("AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING") +AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION = import_from_settings("AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION") +AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE = import_from_settings("AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE") +AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING = import_from_settings( + "AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING" +) + + +# automatically create a compute allocation, called by project_new signal +def add_auto_compute_allocation(project_obj): + """Method to add a compute allocation automatically upon project creation - uses signals for project creation""" + + # if project_code not enabled or None or empty, print appropriate message and stop + if not hasattr(project_obj, "project_code"): + logger.info("Enable project_code to use the auto_compute_allocation plugin") + logger.info( + "Additional message - this issue was encountered with project pk %s", + {project_obj.pk}, + ) + return None + if project_obj.project_code in [None, ""]: + logger.info("None or empty project_code value encountered, please run the project code management command") + logger.info( + "Additional message - this issue was encountered with project pk %s", + {project_obj.pk}, + ) + return None + + project_code = project_obj.project_code + auto_allocation_clusters = get_cluster_resources_tuple() + + if len(auto_allocation_clusters) == 0: + raise Exception("No auto_allocation_clusters found - no resources of type Cluster configured!") + + # accelerator hours + allocation_attribute_type_obj_accelerator_hours = AllocationAttributeType.objects.get( + name="Accelerator Usage (Hours)" + ) + # core hours + allocation_attribute_type_obj_core_hours = AllocationAttributeType.objects.get(name="Core Usage (Hours)") + # slurm account name + allocation_attribute_type_obj_slurm_account_name = AllocationAttributeType.objects.get(name="slurm_account_name") + # slurm specs + allocation_attribute_type_obj_slurm_specs = AllocationAttributeType.objects.get(name="slurm_specs") + # slurm user specs + allocation_attribute_type_obj_slurm_user_specs = AllocationAttributeType.objects.get(name="slurm_user_specs") + + try: + # create the allocation and return it + allocation_obj = allocation_auto_compute(project_obj, project_code) + except Exception as e: + logger.error("Failed to add auto_compute_allocation: %s", e) + + try: + # add all clusters in the tuple, which might just be 1x + allocation_obj.resources.add(*auto_allocation_clusters) + + except Exception as e: + logger.error("Failed to add Cluster resource(s) to auto_compute_allocation: %s", e) + + try: + # allocation user - PI + allocation_auto_compute_pi(project_obj, allocation_obj) + except Exception as e: + logger.error("Failed to add PI to auto_compute_allocation: %s", e) + + # get the format of the slurm account name + local_slurm_account_name = generate_slurm_account_name(allocation_obj, project_obj) + + try: + # add the slurm account name + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_slurm_account_name, + allocation_obj, + local_slurm_account_name, + ) + except Exception as e: + logger.error("Failed to add slurm account name to auto_compute_allocation: %s", e) + + try: + # add slurm user specs + fairshare_value = "Fairshare=parent" + # use generic function + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_slurm_user_specs, + allocation_obj, + fairshare_value, + ) + except Exception as e: + logger.error("Failed to add fairshare value to auto_compute_allocation: %s", e) + + if project_obj.field_of_science.description != "Training": + # 1a) add accelerator hours non-training project - for gauge to appear + if AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS > 0: + accelerator_hours_quantity = AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_accelerator_hours, + allocation_obj, + accelerator_hours_quantity, + ) + # 1b) add core hours non-training project - for gauge to appear + if AUTO_COMPUTE_ALLOCATION_CORE_HOURS > 0: + core_hours_quantity = AUTO_COMPUTE_ALLOCATION_CORE_HOURS + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_core_hours, + allocation_obj, + core_hours_quantity, + ) + # 1c) add slurm attrs non-training project + if len(AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE) > 0: + for slurm_attr in AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE: + new_slurm_attr = slurm_attr.replace(";", ",").replace("'", "") + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_slurm_specs, + allocation_obj, + new_slurm_attr, + ) + + if project_obj.field_of_science.description == "Training": + # 2a) add accelerator hours training project - for gauge to appear + if AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING > 0: + accelerator_hours_quantity = AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_accelerator_hours, + allocation_obj, + accelerator_hours_quantity, + ) + # 2b) add core hours training project - for gauge to appear + if AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING > 0: + core_hours_quantity = AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_core_hours, + allocation_obj, + core_hours_quantity, + ) + # 2c) add slurm attrs training project + if len(AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING) > 0: + for slurm_attr in AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING: + new_slurm_attr = slurm_attr.replace(";", ",").replace("'", "") + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_slurm_specs, + allocation_obj, + new_slurm_attr, + ) + + if AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION: + allocation_auto_compute_fairshare_institution(project_obj, allocation_obj) diff --git a/coldfront/plugins/auto_compute_allocation/utils.py b/coldfront/plugins/auto_compute_allocation/utils.py new file mode 100644 index 0000000000..f6a71414d4 --- /dev/null +++ b/coldfront/plugins/auto_compute_allocation/utils.py @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Coldfront auto_compute_allocation plugin utils.py""" + +import datetime +import logging + +from coldfront.core.allocation.models import ( + Allocation, + AllocationAttribute, + AllocationAttributeType, + AllocationStatusChoice, + AllocationUser, + AllocationUserStatusChoice, +) +from coldfront.core.resource.models import Resource, ResourceType +from coldfront.core.utils.common import import_from_settings +from coldfront.plugins.auto_compute_allocation.fairshare_institution_name import generate_fairshare_institution_name + +# Environment variables for auto_compute_allocation in utils.py +AUTO_COMPUTE_ALLOCATION_END_DELTA = import_from_settings("AUTO_COMPUTE_ALLOCATION_END_DELTA") +AUTO_COMPUTE_ALLOCATION_CHANGEABLE = import_from_settings("AUTO_COMPUTE_ALLOCATION_CHANGEABLE") +AUTO_COMPUTE_ALLOCATION_LOCKED = import_from_settings("AUTO_COMPUTE_ALLOCATION_LOCKED") +AUTO_COMPUTE_ALLOCATION_CLUSTERS = import_from_settings("AUTO_COMPUTE_ALLOCATION_CLUSTERS") +AUTO_COMPUTE_ALLOCATION_DESCRIPTION = import_from_settings("AUTO_COMPUTE_ALLOCATION_DESCRIPTION") + +logger = logging.getLogger(__name__) + + +def get_cluster_resources_tuple(): + """Method to get all cluster Resources configured the Coldfront instance, optionally can filter out using variable""" + # find 'Cluster' within ResourceType + cluster_pk_value = ResourceType.objects.get(name="Cluster").pk + # filter for clusters + resource_queryset = Resource.objects.filter(resource_type=cluster_pk_value) + # initialise a list which will store all clusters - even if just 1x + cluster_list = [] + + # If a filter is defined then find matches + if AUTO_COMPUTE_ALLOCATION_CLUSTERS: + for filter_cluster in AUTO_COMPUTE_ALLOCATION_CLUSTERS: + matched_cluster = Resource.objects.get(name=filter_cluster) + cluster_list.append(matched_cluster.pk) + auto_allocation_clusters = tuple(cluster_list) + # Otherwise all clusters + else: + for a_cluster in resource_queryset: + cluster_list.append(a_cluster.pk) + auto_allocation_clusters = tuple(cluster_list) + return auto_allocation_clusters + + +# create the allocation +def allocation_auto_compute(project_obj, project_code): + """Method to create the auto_compute allocation""" + allocation_start_date = datetime.date.today() + allocation_end_date = allocation_start_date + datetime.timedelta(days=AUTO_COMPUTE_ALLOCATION_END_DELTA) + + allocation_status_obj = AllocationStatusChoice.objects.get(name="New") # alternative is Active + allocation_description = f"{AUTO_COMPUTE_ALLOCATION_DESCRIPTION}{project_code}" + + allocation_obj = Allocation.objects.create( + project=project_obj, + justification="System automatically created compute allocation", + description=allocation_description, + status=allocation_status_obj, + quantity=1, + start_date=allocation_start_date, + end_date=allocation_end_date, + is_locked=AUTO_COMPUTE_ALLOCATION_LOCKED, # admin needs to unlock to permit changes + is_changeable=AUTO_COMPUTE_ALLOCATION_CHANGEABLE, + ) # no ability to request change to this allocation + return allocation_obj + + +def allocation_auto_compute_pi(project_obj, allocation_obj): + """Method to add the PI to the auto_compute allocation - not other users, as they won't exist on project creation""" + allocation_user_obj = AllocationUser.objects.create( + allocation=allocation_obj, + user=project_obj.pi, + status=AllocationUserStatusChoice.objects.get(name="Active"), + ) + return allocation_user_obj + + +def allocation_auto_compute_attribute_create(allocation_attribute_type_obj, allocation_obj, allocation_value): + """generic method to add allocation attribute types and corresponding values""" + allocation_attribute_obj = AllocationAttribute.objects.create( + allocation=allocation_obj, + allocation_attribute_type=allocation_attribute_type_obj, + value=allocation_value, + ) + return allocation_attribute_obj + + +def allocation_auto_compute_fairshare_institution(project_obj, allocation_obj): + """method to add an institutional fair share value for slurm association - slurm specs""" + if not hasattr(project_obj, "institution"): + logger.info("Enable institution feature to set per institution fairshare in the auto_compute_allocation plugin") + logger.info( + "Additional message - this issue was encountered with project pk %s", + {project_obj.pk}, + ) + return None + if project_obj.institution in [None, "", "None"]: + logger.info( + "None or empty institution value encountered, an institution value is required to set per institution fairshare in the auto_compute_allocation plugin - value found was %s", + {project_obj.institution}, + ) + logger.info( + "Additional message - this issue was encountered with project pk", + {project_obj.pk}, + ) + return None + + # get the format for the fairshare institution + fairshare_institution = generate_fairshare_institution_name(project_obj) + + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_specs") + fairshare_value = f"Fairshare={fairshare_institution}" + + AllocationAttribute.objects.create( + allocation=allocation_obj, + allocation_attribute_type=allocation_attribute_type_obj, + value=fairshare_value, + ) diff --git a/coldfront/plugins/freeipa/README.md b/coldfront/plugins/freeipa/README.md index 10a3cd3d30..a34b12a1a8 100644 --- a/coldfront/plugins/freeipa/README.md +++ b/coldfront/plugins/freeipa/README.md @@ -36,9 +36,7 @@ ipaclient python library. ### Install required python packages -- pip install django-q -- pip install ipaclient -- pip install dbus-python +- uv sync --extra ldap --extra freeipa ### Update sssd.conf to enable infopipe diff --git a/coldfront/plugins/freeipa/__init__.py b/coldfront/plugins/freeipa/__init__.py index 4b08178ede..6d24412f63 100644 --- a/coldfront/plugins/freeipa/__init__.py +++ b/coldfront/plugins/freeipa/__init__.py @@ -1 +1,4 @@ -default_app_config = 'coldfront.plugins.freeipa.apps.IPAConfig' +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + diff --git a/coldfront/plugins/freeipa/apps.py b/coldfront/plugins/freeipa/apps.py index 6fc29db35f..d3bb248f8e 100644 --- a/coldfront/plugins/freeipa/apps.py +++ b/coldfront/plugins/freeipa/apps.py @@ -1,12 +1,19 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import importlib + from django.apps import AppConfig from coldfront.core.utils.common import import_from_settings -FREEIPA_ENABLE_SIGNALS = import_from_settings('FREEIPA_ENABLE_SIGNALS', False) +FREEIPA_ENABLE_SIGNALS = import_from_settings("FREEIPA_ENABLE_SIGNALS", False) + class IPAConfig(AppConfig): - name = 'coldfront.plugins.freeipa' + name = "coldfront.plugins.freeipa" def ready(self): if FREEIPA_ENABLE_SIGNALS: - import coldfront.plugins.freeipa.signals + importlib.import_module("coldfront.plugins.freeipa.signals") diff --git a/coldfront/plugins/freeipa/management/__init__.py b/coldfront/plugins/freeipa/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/freeipa/management/__init__.py +++ b/coldfront/plugins/freeipa/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/freeipa/management/commands/__init__.py b/coldfront/plugins/freeipa/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/freeipa/management/commands/__init__.py +++ b/coldfront/plugins/freeipa/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/freeipa/management/commands/freeipa_check.py b/coldfront/plugins/freeipa/management/commands/freeipa_check.py index 01d16aa9a6..f2240ce6a4 100644 --- a/coldfront/plugins/freeipa/management/commands/freeipa_check.py +++ b/coldfront/plugins/freeipa/management/commands/freeipa_check.py @@ -1,96 +1,87 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging import os import sys -import dbus +import dbus from django.contrib.auth.models import User -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from ipalib import api -from ipalib.errors import NotFound +from coldfront.core.allocation.models import AllocationUser, AllocationUserStatusChoice +from coldfront.core.project.models import ProjectUser, ProjectUserStatusChoice from coldfront.plugins.freeipa.search import LDAPUserSearch -from coldfront.core.project.models import (ProjectUser, ProjectUserStatusChoice) -from coldfront.core.allocation.models import (Allocation, AllocationUser, AllocationUserStatusChoice) -from coldfront.plugins.freeipa.utils import (CLIENT_KTNAME, FREEIPA_NOOP, - UNIX_GROUP_ATTRIBUTE_NAME, - AlreadyMemberError, - NotMemberError, - check_ipa_group_error) +from coldfront.plugins.freeipa.utils import ( + CLIENT_KTNAME, + FREEIPA_NOOP, + UNIX_GROUP_ATTRIBUTE_NAME, + ipa_bootstrap, +) logger = logging.getLogger(__name__) class Command(BaseCommand): - help = 'Sync groups in FreeIPA' + help = "Sync groups in FreeIPA" def add_arguments(self, parser): parser.add_argument("-s", "--sync", help="Sync changes to/from FreeIPA", action="store_true") parser.add_argument("-u", "--username", help="Check specific username") parser.add_argument("-g", "--group", help="Check specific group") - parser.add_argument("-d", "--disable", help="Disable users in ColdFront that are Disabled/NotFound in FreeIPA", action="store_true") + parser.add_argument( + "-d", + "--disable", + help="Disable users in ColdFront that are Disabled/NotFound in FreeIPA", + action="store_true", + ) parser.add_argument("-n", "--noop", help="Print commands only. Do not run any commands.", action="store_true") parser.add_argument("-x", "--header", help="Include header in output", action="store_true") def writerow(self, row): try: - self.stdout.write('{0: <12}{1: <20}{2: <30}{3}'.format(*row)) + self.stdout.write("{0: <12}{1: <20}{2: <30}{3}".format(*row)) except BrokenPipeError: devnull = os.open(os.devnull, os.O_WRONLY) os.dup2(devnull, sys.stdout.fileno()) sys.exit(1) def check_ipa_error(self, res): - if not res or 'result' not in res: - raise ValueError('Missing FreeIPA result') + if not res or "result" not in res: + raise ValueError("Missing FreeIPA result") def add_group(self, user, group, status): - if self.sync and not self.noop: - try: - res = api.Command.group_add_member(group, user=[user.username]) - check_ipa_group_error(res) - except AlreadyMemberError as e: - logger.warn("User %s is already a member of group %s", user.username, group) - except Exception as e: - logger.error("Failed adding user %s to group %s: %s", user.username, group, e) - else: - logger.info("Added user %s to group %s successfully", user.username, group) + self.ipa_batch_args.append({"method": "group_add_member", "params": [[group], {"user": [user.username]}]}) row = [ - 'Add', + "Add", user.username, group, - '/'.join([status, 'Active' if user.is_active else 'Inactive']), + "/".join([status, "Active" if user.is_active else "Inactive"]), ] self.writerow(row) def remove_group(self, user, group, status): - if self.sync and not self.noop: - try: - res = api.Command.group_remove_member(group, user=[user.username]) - check_ipa_group_error(res) - except NotMemberError as e: - logger.warn("User %s is not a member of group %s", user.username, group) - except Exception as e: - logger.error("Failed removing user %s from group %s: %s", user.username, group, e) - else: - logger.info("Removed user %s from group %s successfully", user.username, group) + self.ipa_batch_args.append({"method": "group_remove_member", "params": [[group], {"user": [user.username]}]}) row = [ - 'Remove', + "Remove", user.username, group, - '/'.join([status, 'Active' if user.is_active else 'Inactive']), + "/".join([status, "Active" if user.is_active else "Inactive"]), ] self.writerow(row) def disable_user_in_coldfront(self, user, freeipa_status): row = [ - 'Disable', + "Disable", user.username, - '', - '/'.join([freeipa_status, 'Active' if user.is_active else 'Inactive']), + "", + "/".join([freeipa_status, "Active" if user.is_active else "Inactive"]), ] self.writerow(row) @@ -101,19 +92,19 @@ def disable_user_in_coldfront(self, user, freeipa_status): return # Disable user from any active allocations - inactive_status = AllocationUserStatusChoice.objects.get(name='Removed') + inactive_status = AllocationUserStatusChoice.objects.get(name="Removed") user_allocations = AllocationUser.objects.filter(user=user) for ua in user_allocations: - if ua.status.name == 'Active' and ua.allocation.status.name == 'Active': + if ua.status.name == "Active" and ua.allocation.status.name == "Active": logger.info("Removing user from allocation user=%s allocation=%s", user.username, ua.allocation) ua.status = inactive_status ua.save() # Disable user from any active projects - inactive_status = ProjectUserStatusChoice.objects.get(name='Removed') + inactive_status = ProjectUserStatusChoice.objects.get(name="Removed") user_projects = ProjectUser.objects.filter(user=user) for pa in user_projects: - if pa.status.name == 'Active' and pa.project.status.name == 'Active': + if pa.status.name == "Active" and pa.project.status.name == "Active": logger.info("Removing user from project user=%s project=%s", user.username, pa.project) pa.status = inactive_status pa.save() @@ -131,13 +122,15 @@ def sync_user_status(self, user, active=False): user.is_active = active user.save() except Exception as e: - logger.error('Failed to update user status: %s - %s', user.username, e) + logger.error("Failed to update user status: %s - %s", user.username, e) def check_user_freeipa(self, user, active_groups, removed_groups): - logger.info("Checking FreeIPA user=%s active_groups=%s removed_groups=%s", user.username, active_groups, removed_groups) + logger.info( + "Checking FreeIPA user=%s active_groups=%s removed_groups=%s", user.username, active_groups, removed_groups + ) freeipa_groups = [] - freeipa_status = 'Unknown' + freeipa_status = "Unknown" try: result = self.ifp.GetUserGroups(user.username) logger.debug(result) @@ -145,41 +138,76 @@ def check_user_freeipa(self, user, active_groups, removed_groups): users = self.ipa_ldap.search_a_user(user.username, "username_only") if len(users) == 1: - freeipa_status = 'Enabled' + freeipa_status = "Enabled" else: - freeipa_status = 'Disabled' + freeipa_status = "Disabled" except dbus.exceptions.DBusException as e: - if 'No such user' in str(e) or 'NotFound' in str(e): + if "No such user" in str(e) or "NotFound" in str(e): logger.info("Skipping user %s not found in FreeIPA", user.username) - freeipa_status = 'NotFound' + freeipa_status = "NotFound" else: logger.error("dbus error failed to find user %s in FreeIPA: %s", user.username, e) return - if freeipa_status == 'Disabled' and user.is_active: - logger.warn('User is active in coldfront but disabled in FreeIPA: %s', user.username) + if freeipa_status == "Disabled" and user.is_active: + logger.warning("User is active in coldfront but disabled in FreeIPA: %s", user.username) self.sync_user_status(user, active=False) - elif freeipa_status == 'Enabled' and not user.is_active: - logger.warn('User is not active in coldfront but enabled in FreeIPA: %s', user.username) + elif freeipa_status == "Enabled" and not user.is_active: + logger.warning("User is not active in coldfront but enabled in FreeIPA: %s", user.username) self.sync_user_status(user, active=True) for g in active_groups: if g not in freeipa_groups: - logger.info('User %s should be added to freeipa group: %s', user.username, g) + logger.info("User %s should be added to freeipa group: %s", user.username, g) self.add_group(user, g, freeipa_status) for g in removed_groups: if g in freeipa_groups: - logger.info('User %s should be removed from freeipa group: %s', user.username, g) + logger.info("User %s should be removed from freeipa group: %s", user.username, g) self.remove_group(user, g, freeipa_status) + def exec_batch(self): + ipa_bootstrap() + self._set_logging() + batch_args = [] + + for ci, arg in enumerate(self.ipa_batch_args): + if len(batch_args) < self.ipa_batch_size: + batch_args.append(arg) + + if len(batch_args) < self.ipa_batch_size and ci < len(self.ipa_batch_args) - 1: + continue + + result = api.Command.batch(batch_args) + + if len(batch_args) != result["count"]: + logger.error("Result count %d does not match batch size %d", result["count"], len(batch_args)) + if result["count"] > 0: + for ri, res in enumerate(result["results"]): + _res = res.get("result", None) + if "error" not in res or res["error"] is None: + logger.info( + "Success %s for user %s to group %s", + batch_args[ri]["method"], + batch_args[ri]["params"][1]["user"][0], + batch_args[ri]["params"][0][0], + ) + else: + logger.error( + "Failed %s for user %s to group %s: %s", + batch_args[ri]["method"], + batch_args[ri]["params"][1]["user"][0], + batch_args[ri]["params"][0][0], + res["error"], + ) + del batch_args[:] + def process_user(self, user): if self.filter_user and self.filter_user != user.username: return user_allocations = AllocationUser.objects.filter( - user=user, - allocation__allocationattribute__allocation_attribute_type__name=UNIX_GROUP_ATTRIBUTE_NAME + user=user, allocation__allocationattribute__allocation_attribute_type__name=UNIX_GROUP_ATTRIBUTE_NAME ) active_groups = [] @@ -193,7 +221,11 @@ def process_user(self, user): all_resources_inactive = False if all_resources_inactive: - logger.debug("Skipping allocation to %s for user %s due to all resources being inactive", ua.allocation.get_resources_as_string, user.username) + logger.debug( + "Skipping allocation to %s for user %s due to all resources being inactive", + ua.allocation.get_resources_as_string, + user.username, + ) continue for g in ua.allocation.get_attribute_list(UNIX_GROUP_ATTRIBUTE_NAME): @@ -225,66 +257,73 @@ def process_user(self, user): self.check_user_freeipa(user, active_groups, removed_groups) - def handle(self, *args, **options): - os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME - - verbosity = int(options['verbosity']) - root_logger = logging.getLogger('') - if verbosity == 0: + def _set_logging(self): + root_logger = logging.getLogger("") + if self.verbosity == 0: root_logger.setLevel(logging.ERROR) - elif verbosity == 2: + elif self.verbosity == 2: root_logger.setLevel(logging.INFO) - elif verbosity == 3: + elif self.verbosity == 3: root_logger.setLevel(logging.DEBUG) else: - root_logger.setLevel(logging.WARN) + root_logger.setLevel(logging.WARNING) + + def handle(self, *args, **options): + os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME + + self.verbosity = int(options["verbosity"]) + self._set_logging() + self.ipa_batch_args = [] + self.ipa_batch_size = 100 self.noop = FREEIPA_NOOP - if options['noop']: + if options["noop"]: self.noop = True - logger.warn("NOOP enabled") + logger.warning("NOOP enabled") self.sync = False - if options['sync']: + if options["sync"]: self.sync = True - logger.warn("Syncing FreeIPA with ColdFront") + logger.warning("Syncing FreeIPA with ColdFront") self.disable = False - if options['disable']: + if options["disable"]: self.disable = True - logger.warn("Disabling users in ColdFront that are disabled in FreeIPA") + logger.warning("Disabling users in ColdFront that are disabled in FreeIPA") header = [ - 'action', - 'username', - 'group', - 'ipa/cf', + "action", + "username", + "group", + "ipa/cf", ] - if options['header']: + if options["header"]: self.writerow(header) self.ipa_ldap = LDAPUserSearch("", "") bus = dbus.SystemBus() infopipe_obj = bus.get_object("org.freedesktop.sssd.infopipe", "/org/freedesktop/sssd/infopipe") - self.ifp = dbus.Interface(infopipe_obj, dbus_interface='org.freedesktop.sssd.infopipe') + self.ifp = dbus.Interface(infopipe_obj, dbus_interface="org.freedesktop.sssd.infopipe") users = User.objects.filter(is_active=True) logger.info("Processing %s active users", len(users)) - self.filter_user = '' - self.filter_group = '' - if options['username']: - logger.info("Filtering output by username: %s", - options['username']) - self.filter_user = options['username'] - if options['group']: - logger.info("Filtering output by group: %s", options['group']) - self.filter_group = options['group'] + self.filter_user = "" + self.filter_group = "" + if options["username"]: + logger.info("Filtering output by username: %s", options["username"]) + self.filter_user = options["username"] + if options["group"]: + logger.info("Filtering output by group: %s", options["group"]) + self.filter_group = options["group"] for user in users: self.process_user(user) + if self.sync and not self.noop: + self.exec_batch() + if self.disable: for user in users: if self.filter_user and self.filter_user != user.username: @@ -292,15 +331,14 @@ def handle(self, *args, **options): try: result = self.ifp.GetUserAttr(user.username, ["nsaccountlock"]) - if 'nsAccountLock' in result and str(result['nsAccountLock'][0]) == 'TRUE': + if "nsAccountLock" in result and str(result["nsAccountLock"][0]) == "TRUE": # User is disabled in FreeIPA so disable in coldfront logger.info("User is disabled in FreeIPA so disable in ColdFront: %s", user.username) - self.disable_user_in_coldfront(user, 'Disabled') + self.disable_user_in_coldfront(user, "Disabled") except dbus.exceptions.DBusException as e: - if 'No such user' in str(e) or 'NotFound' in str(e): + if "No such user" in str(e) or "NotFound" in str(e): # User is not found in FreeIPA so disable in coldfront logger.info("User is not found in FreeIPA so disable in ColdFront: %s", user.username) - self.disable_user_in_coldfront(user, 'NotFound') + self.disable_user_in_coldfront(user, "NotFound") else: logger.error("dbus error failed while checking user %s in FreeIPA: %s", user.username, e) - diff --git a/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py b/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py index bbad1409b1..49992c5a90 100644 --- a/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py +++ b/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py @@ -1,22 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import datetime import logging import os import sys -import datetime -import dbus +import dbus from django.core.management.base import BaseCommand from django.urls import reverse from ipalib import api -from ipalib.errors import NotFound +from coldfront.core.allocation.models import AllocationUser from coldfront.core.utils.mail import build_link -from coldfront.core.allocation.models import Allocation, AllocationUser -from coldfront.plugins.freeipa.utils import (CLIENT_KTNAME, FREEIPA_NOOP) +from coldfront.plugins.freeipa.utils import CLIENT_KTNAME, FREEIPA_NOOP, ipa_bootstrap logger = logging.getLogger(__name__) + class Command(BaseCommand): - help = 'Report users to expire in FreeIPA' + help = "Report users to expire in FreeIPA" def add_arguments(self, parser): parser.add_argument("-s", "--sync", help="Sync changes to/from FreeIPA", action="store_true") @@ -25,7 +29,7 @@ def add_arguments(self, parser): def writerow(self, row): try: - self.stdout.write('{0: <20}{1: <15}{2}'.format(*row)) + self.stdout.write("{0: <20}{1: <15}{2}".format(*row)) except BrokenPipeError: devnull = os.open(os.devnull, os.O_WRONLY) os.dup2(devnull, sys.stdout.fileno()) @@ -34,8 +38,8 @@ def writerow(self, row): def handle(self, *args, **options): os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME - verbosity = int(options['verbosity']) - root_logger = logging.getLogger('') + verbosity = int(options["verbosity"]) + root_logger = logging.getLogger("") if verbosity == 0: root_logger.setLevel(logging.ERROR) elif verbosity == 2: @@ -43,41 +47,44 @@ def handle(self, *args, **options): elif verbosity == 3: root_logger.setLevel(logging.DEBUG) else: - root_logger.setLevel(logging.WARN) + root_logger.setLevel(logging.WARNING) self.sync = False - if options['sync']: + if options["sync"]: self.sync = True - logger.warn("Syncing FreeIPA with ColdFront") + logger.warning("Syncing FreeIPA with ColdFront") self.noop = FREEIPA_NOOP - if options['noop']: + if options["noop"]: self.noop = True - logger.warn("NOOP enabled") + logger.warning("NOOP enabled") header = [ - 'username', - 'expire_date', - 'allocation', + "username", + "expire_date", + "allocation", ] - if options['header']: + if options["header"]: self.writerow(header) bus = dbus.SystemBus() infopipe_obj = bus.get_object("org.freedesktop.sssd.infopipe", "/org/freedesktop/sssd/infopipe") - ifp = dbus.Interface(infopipe_obj, dbus_interface='org.freedesktop.sssd.infopipe') - + ifp = dbus.Interface(infopipe_obj, dbus_interface="org.freedesktop.sssd.infopipe") expired_365_days_ago = datetime.datetime.today() - datetime.timedelta(days=365) expired_365_days_ago = expired_365_days_ago.date() # Find all active users on active allocations - active_users = sorted(list(set( - AllocationUser.objects.filter( - status__name='Active' - ).exclude(allocation__status__name__in=['Expired']).values_list('user__username', flat=True) - ))) + active_users = sorted( + list( + set( + AllocationUser.objects.filter(status__name="Active") + .exclude(allocation__status__name__in=["Expired"]) + .values_list("user__username", flat=True) + ) + ) + ) # Filter out users to expire, either not active or have been removed expired_allocation_users = {} @@ -87,59 +94,71 @@ def handle(self, *args, **options): allocation = allocationuser.allocation expire_date = allocation.end_date - if allocation.status.name != 'Expired' and allocationuser.status.name == 'Removed': + if allocation.status.name != "Expired" and allocationuser.status.name == "Removed": expire_date = allocationuser.modified.date() if not expire_date: - logger.info("Unable to find expire date for user=%s allocation_id=%s", allocationuser.user.username, allocation.id) + logger.info( + "Unable to find expire date for user=%s allocation_id=%s", + allocationuser.user.username, + allocation.id, + ) continue if allocationuser.user.username not in expired_allocation_users: expired_allocation_users[allocationuser.user.username] = { - 'user': allocationuser.user, - 'expire_date': expire_date, - 'allocation_id': allocation.id + "user": allocationuser.user, + "expire_date": expire_date, + "allocation_id": allocation.id, } else: - if expire_date > expired_allocation_users[allocationuser.user.username]['expire_date']: + if expire_date > expired_allocation_users[allocationuser.user.username]["expire_date"]: expired_allocation_users[allocationuser.user.username] = { - 'user': allocationuser.user, - 'expire_date': expire_date, - 'allocation_id': allocation.id + "user": allocationuser.user, + "expire_date": expire_date, + "allocation_id": allocation.id, } + ipa_bootstrap() + # Print users whose latest allocation expiration date GTE 365 days and active in FreeIPA for key in expired_allocation_users.keys(): - if expired_allocation_users[key]['expire_date'] > expired_365_days_ago: + if expired_allocation_users[key]["expire_date"] > expired_365_days_ago: continue try: result = ifp.GetUserAttr(key, ["nsaccountlock"]) - if 'nsAccountLock' in result and str(result['nsAccountLock'][0]).lower() == 'true': + if "nsAccountLock" in result and str(result["nsAccountLock"][0]).lower() == "true": # User is already disabled in FreeIPA so do nothing logger.info("User already disabled in FreeIPA: %s", key) pass else: # User is active in FreeIPA but not on any active allocations - self.writerow([ - key, - expired_allocation_users[key]['expire_date'].strftime("%Y-%m-%d"), - build_link(reverse('allocation-detail', kwargs={'pk': expired_allocation_users[key]['allocation_id']})) - ]) + self.writerow( + [ + key, + expired_allocation_users[key]["expire_date"].strftime("%Y-%m-%d"), + build_link( + reverse( + "allocation-detail", kwargs={"pk": expired_allocation_users[key]["allocation_id"]} + ) + ), + ] + ) if self.sync and not self.noop: # Disable in ColdFront - expired_allocation_users[key]['user'].is_active = False - expired_allocation_users[key]['user'].save() + expired_allocation_users[key]["user"].is_active = False + expired_allocation_users[key]["user"].save() # Disable in FreeIPA res = api.Command.user_disable(key) if not res: - raise ValueError('Missing FreeIPA response') - if 'result' not in res or not res['result']: - raise ValueError(f'Failed to disable user: {res}') + raise ValueError("Missing FreeIPA response") + if "result" not in res or not res["result"]: + raise ValueError(f"Failed to disable user: {res}") except dbus.exceptions.DBusException as e: - if 'No such user' in str(e) or 'NotFound' in str(e): + if "No such user" in str(e) or "NotFound" in str(e): logger.info("User %s not found in FreeIPA", key) else: logger.error("dbus error failed to find user %s in FreeIPA: %s", key, e) diff --git a/coldfront/plugins/freeipa/requirements.txt b/coldfront/plugins/freeipa/requirements.txt deleted file mode 100644 index 08b8c5a59e..0000000000 --- a/coldfront/plugins/freeipa/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -ipaclient==4.7.2 -gssapi==1.5.1 -ldap3==2.6 -dbus-python==1.2.8 diff --git a/coldfront/plugins/freeipa/search.py b/coldfront/plugins/freeipa/search.py index 2c7cf8a7eb..12da2f9cab 100644 --- a/coldfront/plugins/freeipa/search.py +++ b/coldfront/plugins/freeipa/search.py @@ -1,27 +1,33 @@ -import os +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import json import logging +import os import ldap.filter +from django.core.exceptions import ImproperlyConfigured +from ldap3 import KERBEROS, SASL, Connection, Server + from coldfront.core.user.utils import UserSearch from coldfront.core.utils.common import import_from_settings -from django.core.exceptions import ImproperlyConfigured -from ldap3 import Connection, Server, SASL, KERBEROS logger = logging.getLogger(__name__) + class LDAPUserSearch(UserSearch): - search_source = 'LDAP' + search_source = "LDAP" def __init__(self, user_search_string, search_by): super().__init__(user_search_string, search_by) - self.FREEIPA_SERVER = import_from_settings('FREEIPA_SERVER') - self.FREEIPA_USER_SEARCH_BASE = import_from_settings('FREEIPA_USER_SEARCH_BASE', 'cn=users,cn=accounts') - self.FREEIPA_KTNAME = import_from_settings('FREEIPA_KTNAME', '') + self.FREEIPA_SERVER = import_from_settings("FREEIPA_SERVER") + self.FREEIPA_USER_SEARCH_BASE = import_from_settings("FREEIPA_USER_SEARCH_BASE", "cn=users,cn=accounts") + self.FREEIPA_KTNAME = import_from_settings("FREEIPA_KTNAME", "") - self.server = Server('ldap://{}'.format(self.FREEIPA_SERVER), use_ssl=True, connect_timeout=1) + self.server = Server("ldap://{}".format(self.FREEIPA_SERVER), use_ssl=True, connect_timeout=1) if len(self.FREEIPA_KTNAME) > 0: - logger.info('Kerberos bind enabled: %s', self.FREEIPA_KTNAME) + logger.info("Kerberos bind enabled: %s", self.FREEIPA_KTNAME) # kerberos SASL/GSSAPI bind os.environ["KRB5_CLIENT_KTNAME"] = self.FREEIPA_KTNAME self.conn = Connection(self.server, authentication=SASL, sasl_mechanism=KERBEROS, auto_bind=True) @@ -30,39 +36,46 @@ def __init__(self, user_search_string, search_by): self.conn = Connection(self.server, auto_bind=True) if not self.conn.bind(): - raise ImproperlyConfigured('Failed to bind to LDAP server: {}'.format(self.conn.result)) + raise ImproperlyConfigured("Failed to bind to LDAP server: {}".format(self.conn.result)) else: - logger.info('LDAP bind successful: %s', self.conn.extend.standard.who_am_i()) + logger.info("LDAP bind successful: %s", self.conn.extend.standard.who_am_i()) def parse_ldap_entry(self, entry): - entry_dict = json.loads(entry.entry_to_json()).get('attributes') + entry_dict = json.loads(entry.entry_to_json()).get("attributes") user_dict = { - 'last_name': entry_dict.get('sn')[0] if entry_dict.get('sn') else '', - 'first_name': entry_dict.get('givenName')[0] if entry_dict.get('givenName') else '', - 'username': entry_dict.get('uid')[0] if entry_dict.get('uid') else '', - 'email': entry_dict.get('mail')[0] if entry_dict.get('mail') else '', - 'source': self.search_source, + "last_name": entry_dict.get("sn")[0] if entry_dict.get("sn") else "", + "first_name": entry_dict.get("givenName")[0] if entry_dict.get("givenName") else "", + "username": entry_dict.get("uid")[0] if entry_dict.get("uid") else "", + "email": entry_dict.get("mail")[0] if entry_dict.get("mail") else "", + "source": self.search_source, } return user_dict - def search_a_user(self, user_search_string=None, search_by='all_fields'): + def search_a_user(self, user_search_string=None, search_by="all_fields"): os.environ["KRB5_CLIENT_KTNAME"] = self.FREEIPA_KTNAME size_limit = 50 - if user_search_string and search_by == 'all_fields': - filter = ldap.filter.filter_format("(&(|(givenName=*%s*)(sn=*%s*)(uid=*%s*)(mail=*%s*))(|(nsaccountlock=FALSE)(!(nsaccountlock=*))))", [user_search_string] * 4) - elif user_search_string and search_by == 'username_only': - filter = ldap.filter.filter_format("(&(uid=%s)(|(nsaccountlock=FALSE)(!(nsaccountlock=*))))", [user_search_string]) + if user_search_string and search_by == "all_fields": + filter = ldap.filter.filter_format( + "(&(|(givenName=*%s*)(sn=*%s*)(uid=*%s*)(mail=*%s*))(|(nsaccountlock=FALSE)(!(nsaccountlock=*))))", + [user_search_string] * 4, + ) + elif user_search_string and search_by == "username_only": + filter = ldap.filter.filter_format( + "(&(uid=%s)(|(nsaccountlock=FALSE)(!(nsaccountlock=*))))", [user_search_string] + ) size_limit = 1 else: - filter = '(objectclass=person)' + filter = "(objectclass=person)" - searchParameters = {'search_base': self.FREEIPA_USER_SEARCH_BASE, - 'search_filter': filter, - 'attributes': ['uid', 'sn', 'givenName', 'mail'], - 'size_limit': size_limit} + searchParameters = { + "search_base": self.FREEIPA_USER_SEARCH_BASE, + "search_filter": filter, + "attributes": ["uid", "sn", "givenName", "mail"], + "size_limit": size_limit, + } self.conn.search(**searchParameters) users = [] for idx, entry in enumerate(self.conn.entries, 1): diff --git a/coldfront/plugins/freeipa/signals.py b/coldfront/plugins/freeipa/signals.py index e54782dd91..ebcaa6246d 100644 --- a/coldfront/plugins/freeipa/signals.py +++ b/coldfront/plugins/freeipa/signals.py @@ -1,28 +1,25 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.dispatch import receiver from django_q.tasks import async_task -from coldfront.core.allocation.signals import (allocation_activate_user, - allocation_remove_user) -from coldfront.core.allocation.views import (AllocationAddUsersView, - AllocationRemoveUsersView, - AllocationRenewView) -from coldfront.core.project.views import (ProjectAddUsersView, - ProjectRemoveUsersView) -from coldfront.core.utils.common import import_from_settings +from coldfront.core.allocation.signals import allocation_activate_user, allocation_remove_user +from coldfront.core.allocation.views import AllocationAddUsersView, AllocationRemoveUsersView, AllocationRenewView +from coldfront.core.project.views import ProjectAddUsersView, ProjectRemoveUsersView @receiver(allocation_activate_user, sender=ProjectAddUsersView) @receiver(allocation_activate_user, sender=AllocationAddUsersView) def activate_user(sender, **kwargs): - allocation_user_pk = kwargs.get('allocation_user_pk') - async_task('coldfront.plugins.freeipa.tasks.add_user_group', - allocation_user_pk) + allocation_user_pk = kwargs.get("allocation_user_pk") + async_task("coldfront.plugins.freeipa.tasks.add_user_group", allocation_user_pk) @receiver(allocation_remove_user, sender=ProjectRemoveUsersView) @receiver(allocation_remove_user, sender=AllocationRemoveUsersView) @receiver(allocation_remove_user, sender=AllocationRenewView) def remove_user(sender, **kwargs): - allocation_user_pk = kwargs.get('allocation_user_pk') - async_task('coldfront.plugins.freeipa.tasks.remove_user_group', - allocation_user_pk) + allocation_user_pk = kwargs.get("allocation_user_pk") + async_task("coldfront.plugins.freeipa.tasks.remove_user_group", allocation_user_pk) diff --git a/coldfront/plugins/freeipa/tasks.py b/coldfront/plugins/freeipa/tasks.py index 95d2e62ad2..1ba930b0a7 100644 --- a/coldfront/plugins/freeipa/tasks.py +++ b/coldfront/plugins/freeipa/tasks.py @@ -1,86 +1,96 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging import os -from django.contrib.auth.models import User from ipalib import api from coldfront.core.allocation.models import Allocation, AllocationUser from coldfront.core.allocation.utils import set_allocation_user_status_to_error -from coldfront.plugins.freeipa.utils import (CLIENT_KTNAME, FREEIPA_NOOP, - UNIX_GROUP_ATTRIBUTE_NAME, - AlreadyMemberError, ApiError, - NotMemberError, - check_ipa_group_error) +from coldfront.plugins.freeipa.utils import ( + CLIENT_KTNAME, + FREEIPA_NOOP, + UNIX_GROUP_ATTRIBUTE_NAME, + AlreadyMemberError, + NotMemberError, + check_ipa_group_error, + ipa_bootstrap, +) logger = logging.getLogger(__name__) def add_user_group(allocation_user_pk): allocation_user = AllocationUser.objects.get(pk=allocation_user_pk) - if allocation_user.allocation.status.name != 'Active': - logger.warn("Allocation is not active. Will not add groups") + if allocation_user.allocation.status.name != "Active": + logger.warning("Allocation is not active. Will not add groups") return - if allocation_user.status.name != 'Active': - logger.warn( - "Allocation user status is not 'Active'. Will not add groups.") + if allocation_user.status.name != "Active": + logger.warning("Allocation user status is not 'Active'. Will not add groups.") return - groups = allocation_user.allocation.get_attribute_list( - UNIX_GROUP_ATTRIBUTE_NAME) + groups = allocation_user.allocation.get_attribute_list(UNIX_GROUP_ATTRIBUTE_NAME) if len(groups) == 0: logger.info("Allocation does not have any groups. Nothing to add") return os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME + ipa_bootstrap() for g in groups: if FREEIPA_NOOP: - logger.warn("NOOP - FreeIPA adding user %s to group %s for allocation %s", - allocation_user.user.username, g, allocation_user.allocation) + logger.warning( + "NOOP - FreeIPA adding user %s to group %s for allocation %s", + allocation_user.user.username, + g, + allocation_user.allocation, + ) continue try: - res = api.Command.group_add_member( - g, user=[allocation_user.user.username]) + res = api.Command.group_add_member(g, user=[allocation_user.user.username]) check_ipa_group_error(res) - except AlreadyMemberError as e: - logger.warn("User %s is already a member of group %s", - allocation_user.user.username, g) + except AlreadyMemberError: + logger.warning("User %s is already a member of group %s", allocation_user.user.username, g) except Exception as e: - logger.error("Failed adding user %s to group %s: %s", - allocation_user.user.username, g, e) + logger.error("Failed adding user %s to group %s: %s", allocation_user.user.username, g, e) set_allocation_user_status_to_error(allocation_user_pk) else: - logger.info("Added user %s to group %s successfully", - allocation_user.user.username, g) + logger.info("Added user %s to group %s successfully", allocation_user.user.username, g) def remove_user_group(allocation_user_pk): allocation_user = AllocationUser.objects.get(pk=allocation_user_pk) - if allocation_user.allocation.status.name not in ['Active', 'Pending', 'Inactive (Renewed)', ]: - logger.warn( - "Allocation is not active or pending. Will not remove groups.") + if allocation_user.allocation.status.name not in [ + "Active", + "Pending", + ]: + logger.warning("Allocation is not active or pending. Will not remove groups.") return - if allocation_user.status.name != 'Removed': - logger.warn( - "Allocation user status is not 'Removed'. Will not remove groups.") + if allocation_user.status.name != "Removed": + logger.warning("Allocation user status is not 'Removed'. Will not remove groups.") return - groups = allocation_user.allocation.get_attribute_list( - UNIX_GROUP_ATTRIBUTE_NAME) + groups = allocation_user.allocation.get_attribute_list(UNIX_GROUP_ATTRIBUTE_NAME) if len(groups) == 0: logger.info("Allocation does not have any groups. Nothing to remove") return # Check other active allocations the user is active on for FreeIPA groups # and ensure we don't remove them. - user_allocations = Allocation.objects.filter( - allocationuser__user=allocation_user.user, - allocationuser__status__name='Active', - status__name='Active', - allocationattribute__allocation_attribute_type__name=UNIX_GROUP_ATTRIBUTE_NAME - ).exclude(pk=allocation_user.allocation.pk).distinct() + user_allocations = ( + Allocation.objects.filter( + allocationuser__user=allocation_user.user, + allocationuser__status__name="Active", + status__name="Active", + allocationattribute__allocation_attribute_type__name=UNIX_GROUP_ATTRIBUTE_NAME, + ) + .exclude(pk=allocation_user.allocation.pk) + .distinct() + ) exclude = [] for a in user_allocations: @@ -92,28 +102,28 @@ def remove_user_group(allocation_user_pk): groups.remove(g) if len(groups) == 0: - logger.info( - "No groups to remove. User may belong to these groups in other active allocations: %s", exclude) + logger.info("No groups to remove. User may belong to these groups in other active allocations: %s", exclude) return os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME + ipa_bootstrap() for g in groups: if FREEIPA_NOOP: - logger.warn("NOOP - FreeIPA removing user %s from group %s for allocation %s", - allocation_user.user.username, g, allocation_user.allocation) + logger.warning( + "NOOP - FreeIPA removing user %s from group %s for allocation %s", + allocation_user.user.username, + g, + allocation_user.allocation, + ) continue try: - res = api.Command.group_remove_member( - g, user=[allocation_user.user.username]) + res = api.Command.group_remove_member(g, user=[allocation_user.user.username]) check_ipa_group_error(res) - except NotMemberError as e: - logger.warn("User %s is not a member of group %s", - allocation_user.user.username, g) + except NotMemberError: + logger.warning("User %s is not a member of group %s", allocation_user.user.username, g) except Exception as e: - logger.error("Failed removing user %s from group %s: %s", - allocation_user.user.username, g, e) + logger.error("Failed removing user %s from group %s: %s", allocation_user.user.username, g, e) set_allocation_user_status_to_error(allocation_user_pk) else: - logger.info("Removed user %s from group %s successfully", - allocation_user.user.username, g) + logger.info("Removed user %s from group %s successfully", allocation_user.user.username, g) diff --git a/coldfront/plugins/freeipa/utils.py b/coldfront/plugins/freeipa/utils.py index e3bf5ef995..4f5d65247c 100644 --- a/coldfront/plugins/freeipa/utils.py +++ b/coldfront/plugins/freeipa/utils.py @@ -1,52 +1,62 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging import os from django.core.exceptions import ImproperlyConfigured -from coldfront.core.utils.common import import_from_settings - from ipalib import api -CLIENT_KTNAME = import_from_settings('FREEIPA_KTNAME') -UNIX_GROUP_ATTRIBUTE_NAME = import_from_settings('FREEIPA_GROUP_ATTRIBUTE_NAME', 'freeipa_group') -FREEIPA_NOOP = import_from_settings('FREEIPA_NOOP', False) +from coldfront.core.utils.common import import_from_settings + +CLIENT_KTNAME = import_from_settings("FREEIPA_KTNAME") +UNIX_GROUP_ATTRIBUTE_NAME = import_from_settings("FREEIPA_GROUP_ATTRIBUTE_NAME", "freeipa_group") +FREEIPA_NOOP = import_from_settings("FREEIPA_NOOP", False) logger = logging.getLogger(__name__) + class ApiError(Exception): pass + class AlreadyMemberError(ApiError): pass + class NotMemberError(ApiError): pass -try: - os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME - api.bootstrap() - api.finalize() - api.Backend.rpcclient.connect() -except Exception as e: - logger.error("Failed to initialze FreeIPA lib: %s", e) - raise ImproperlyConfigured('Failed to initialze FreeIPA: {0}'.format(e)) + +def ipa_bootstrap(): + try: + os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME + api.bootstrap(context="client", in_server=False) + api.finalize() + api.Backend.rpcclient.connect() + except Exception as e: + logger.error("Failed to initialze FreeIPA lib: %s", e) + raise ImproperlyConfigured("Failed to initialze FreeIPA: {0}".format(e)) + def check_ipa_group_error(res): if not res: - raise ValueError('Missing FreeIPA response') + raise ValueError("Missing FreeIPA response") - if res['completed'] == 1: + if res["completed"] == 1: return - user = res['failed']['member']['user'][0][0] - group = res['result']['cn'][0] - err_msg = res['failed']['member']['user'][0][1] + res["failed"]["member"]["user"][0][0] + res["result"]["cn"][0] + err_msg = res["failed"]["member"]["user"][0][1] # Check if user is already a member - if err_msg == 'This entry is already a member': + if err_msg == "This entry is already a member": raise AlreadyMemberError(err_msg) # Check if user is not a member - if err_msg == 'This entry is not a member': + if err_msg == "This entry is not a member": raise NotMemberError(err_msg) raise ApiError(err_msg) diff --git a/coldfront/plugins/iquota/README.md b/coldfront/plugins/iquota/README.md index fa662a9d53..a4cdfff183 100644 --- a/coldfront/plugins/iquota/README.md +++ b/coldfront/plugins/iquota/README.md @@ -12,7 +12,7 @@ to authenticate to the API and a valid keytab file is required. ## Requirements -- pip install kerberos humanize requests +- uv sync --extra iquota ## Usage diff --git a/coldfront/plugins/iquota/__init__.py b/coldfront/plugins/iquota/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/iquota/__init__.py +++ b/coldfront/plugins/iquota/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/iquota/admin.py b/coldfront/plugins/iquota/admin.py index 8c38f3f3da..97070bc06b 100644 --- a/coldfront/plugins/iquota/admin.py +++ b/coldfront/plugins/iquota/admin.py @@ -1,3 +1,5 @@ -from django.contrib import admin +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Register your models here. diff --git a/coldfront/plugins/iquota/apps.py b/coldfront/plugins/iquota/apps.py index f257a970b2..87b7756bf2 100644 --- a/coldfront/plugins/iquota/apps.py +++ b/coldfront/plugins/iquota/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class IquotaConfig(AppConfig): - name = 'coldfront.plugins.iquota' + name = "coldfront.plugins.iquota" diff --git a/coldfront/plugins/iquota/exceptions.py b/coldfront/plugins/iquota/exceptions.py index 747362931c..e7167eb73d 100644 --- a/coldfront/plugins/iquota/exceptions.py +++ b/coldfront/plugins/iquota/exceptions.py @@ -1,3 +1,8 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + + class IquotaError(Exception): """Base error class.""" @@ -7,9 +12,11 @@ def __init__(self, message): class KerberosError(IquotaError): """Kerberos Auth error""" + pass class MissingQuotaError(IquotaError): """User request error""" + pass diff --git a/coldfront/plugins/iquota/migrations/__init__.py b/coldfront/plugins/iquota/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/iquota/migrations/__init__.py +++ b/coldfront/plugins/iquota/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/iquota/requirements.txt b/coldfront/plugins/iquota/requirements.txt deleted file mode 100644 index 9cfb7ca74f..0000000000 --- a/coldfront/plugins/iquota/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -kerberos==1.3.0 diff --git a/coldfront/plugins/iquota/templates/iquota/iquota.html b/coldfront/plugins/iquota/templates/iquota/iquota.html index 8421aa99ce..75c840ad6e 100644 --- a/coldfront/plugins/iquota/templates/iquota/iquota.html +++ b/coldfront/plugins/iquota/templates/iquota/iquota.html @@ -30,7 +30,7 @@ $("#quota-button").click(function() { - $('#iquota_inner_button').html(''); + $('#iquota_inner_button').html(''); $.ajax({ url: "{% url 'get-isilon-quota' %}", diff --git a/coldfront/plugins/iquota/templates/iquota/iquota_div.html b/coldfront/plugins/iquota/templates/iquota/iquota_div.html index 638f0b3161..ed35e92d39 100644 --- a/coldfront/plugins/iquota/templates/iquota/iquota_div.html +++ b/coldfront/plugins/iquota/templates/iquota/iquota_div.html @@ -10,7 +10,7 @@

CCR Storage Quotas

diff --git a/coldfront/plugins/slurm/templates/slurm/slurm_help.html b/coldfront/plugins/slurm/templates/slurm/slurm_help.html new file mode 100644 index 0000000000..f866ce2749 --- /dev/null +++ b/coldfront/plugins/slurm/templates/slurm/slurm_help.html @@ -0,0 +1,42 @@ +{% if slurm_info %} +
+
+

Submitting Slurm Jobs

+
+
+

Interactive Jobs

+
+
    + {% for resources_name, submit_info in slurm_info.items %} + {% if submit_info %} +
  • + srun + {% for slurm_submit_option, submit_info_value in submit_info.items %} + {{ slurm_submit_option }} {{ submit_info_value }} + {% endfor %} + <other options> +
  • + {% endif %} + {% endfor %} +
+
+

Batch Jobs

+
+
+ {% for resources_name, submit_info in slurm_info.items %} + {% if submit_info %} +
+
    + {% for slurm_submit_option, submit_info_value in submit_info.items %} +
  • + #SBATCH {{ slurm_submit_option }} {{ submit_info_value }} +
  • + {% endfor %} +
+
+ {% endif %} + {% endfor %} +
+
+
+{% endif %} diff --git a/coldfront/plugins/slurm/templates/slurm/slurm_help_div.html b/coldfront/plugins/slurm/templates/slurm/slurm_help_div.html new file mode 100644 index 0000000000..f45cf2fc4f --- /dev/null +++ b/coldfront/plugins/slurm/templates/slurm/slurm_help_div.html @@ -0,0 +1,19 @@ +
+ + diff --git a/coldfront/plugins/slurm/tests/test_associations.py b/coldfront/plugins/slurm/tests/test_associations.py index a3fc4f1a41..7d97ea55ef 100644 --- a/coldfront/plugins/slurm/tests/test_associations.py +++ b/coldfront/plugins/slurm/tests/test_associations.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from io import StringIO from django.core.management import call_command @@ -5,30 +9,29 @@ from coldfront.core.resource.models import Resource from coldfront.plugins.slurm.associations import SlurmCluster -from coldfront.plugins.slurm.utils import SLURM_CLUSTER_ATTRIBUTE_NAME class AssociationTest(TestCase): - fixtures = ['test_data.json'] + fixtures = ["test_data.json"] @classmethod def setUpClass(cls): - call_command('import_field_of_science_data') - call_command('add_default_grant_options') - call_command('add_default_project_choices') - call_command('add_default_allocation_choices') - call_command('add_default_publication_sources') + call_command("import_field_of_science_data") + call_command("add_default_grant_options") + call_command("add_default_project_choices") + call_command("add_default_allocation_choices") + call_command("add_default_publication_sources") super(AssociationTest, cls).setUpClass() def test_allocations_to_slurm(self): - resource = Resource.objects.get(name='University HPC') + resource = Resource.objects.get(name="University HPC") cluster = SlurmCluster.new_from_resource(resource) - self.assertEqual(cluster.name, 'university-hpc') + self.assertEqual(cluster.name, "university-hpc") self.assertEqual(len(cluster.accounts), 1) - self.assertIn('ccollins', cluster.accounts) - self.assertEqual(len(cluster.accounts['ccollins'].users), 3) - for u in ['ccollins', 'radams', 'mlopez']: - self.assertIn(u, cluster.accounts['ccollins'].users) + self.assertIn("ccollins", cluster.accounts) + self.assertEqual(len(cluster.accounts["ccollins"].users), 3) + for u in ["ccollins", "radams", "mlopez"]: + self.assertIn(u, cluster.accounts["ccollins"].users) def test_parse_sacctmgr_roundtrip(self): dump = StringIO(""" @@ -56,12 +59,12 @@ def test_parse_sacctmgr_roundtrip(self): # Parse sacctmgr dump format cluster = SlurmCluster.new_from_stream(dump) - self.assertEqual(cluster.name, 'alpha') + self.assertEqual(cluster.name, "alpha") self.assertEqual(len(cluster.accounts), 2) - self.assertIn('physics', cluster.accounts) - self.assertEqual(len(cluster.accounts['physics'].users), 3) - for u in ['jane', 'john', 'larry']: - self.assertIn(u, cluster.accounts['physics'].users) + self.assertIn("physics", cluster.accounts) + self.assertEqual(len(cluster.accounts["physics"].users), 3) + for u in ["jane", "john", "larry"]: + self.assertIn(u, cluster.accounts["physics"].users) # Write sacctmgr dump format out = StringIO("") @@ -69,9 +72,9 @@ def test_parse_sacctmgr_roundtrip(self): # Roundtrip cluster2 = SlurmCluster.new_from_stream(StringIO(out.getvalue())) - self.assertEqual(cluster2.name, 'alpha') + self.assertEqual(cluster2.name, "alpha") self.assertEqual(len(cluster2.accounts), 2) - self.assertIn('physics', cluster2.accounts) - self.assertEqual(len(cluster2.accounts['physics'].users), 3) - for u in ['jane', 'john', 'larry']: - self.assertIn(u, cluster2.accounts['physics'].users) + self.assertIn("physics", cluster2.accounts) + self.assertEqual(len(cluster2.accounts["physics"].users), 3) + for u in ["jane", "john", "larry"]: + self.assertIn(u, cluster2.accounts["physics"].users) diff --git a/coldfront/plugins/slurm/urls.py b/coldfront/plugins/slurm/urls.py new file mode 100644 index 0000000000..3cecf11ff9 --- /dev/null +++ b/coldfront/plugins/slurm/urls.py @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.urls import path + +from coldfront.plugins.slurm.views import get_full_slurm_help, get_slurm_help + +urlpatterns = [ + path("full-slurm-help/", get_full_slurm_help, name="full-slurm-help"), + path("slurm-help/", get_slurm_help, name="slurm-help"), +] diff --git a/coldfront/plugins/slurm/utils.py b/coldfront/plugins/slurm/utils.py index 242e5801e9..7f8bf9db48 100644 --- a/coldfront/plugins/slurm/utils.py +++ b/coldfront/plugins/slurm/utils.py @@ -1,61 +1,72 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import csv import logging import shlex import subprocess -import csv from io import StringIO from coldfront.core.utils.common import import_from_settings -SLURM_CLUSTER_ATTRIBUTE_NAME = import_from_settings('SLURM_CLUSTER_ATTRIBUTE_NAME', 'slurm_cluster') -SLURM_ACCOUNT_ATTRIBUTE_NAME = import_from_settings('SLURM_ACCOUNT_ATTRIBUTE_NAME', 'slurm_account_name') -SLURM_SPECS_ATTRIBUTE_NAME = import_from_settings('SLURM_SPECS_ATTRIBUTE_NAME', 'slurm_specs') -SLURM_USER_SPECS_ATTRIBUTE_NAME = import_from_settings('SLURM_USER_SPECS_ATTRIBUTE_NAME', 'slurm_user_specs') -SLURM_SACCTMGR_PATH = import_from_settings('SLURM_SACCTMGR_PATH', '/usr/bin/sacctmgr') -SLURM_CMD_REMOVE_USER = SLURM_SACCTMGR_PATH + ' -Q -i delete user where name={} cluster={} account={}' -SLURM_CMD_REMOVE_QOS = SLURM_SACCTMGR_PATH + ' -Q -i modify user where name={} cluster={} account={} set {}' -SLURM_CMD_REMOVE_ACCOUNT = SLURM_SACCTMGR_PATH + ' -Q -i delete account where name={} cluster={}' -SLURM_CMD_ADD_ACCOUNT = SLURM_SACCTMGR_PATH + ' -Q -i create account name={} cluster={}' -SLURM_CMD_ADD_USER = SLURM_SACCTMGR_PATH + ' -Q -i create user name={} cluster={} account={}' -SLURM_CMD_CHECK_ASSOCIATION = SLURM_SACCTMGR_PATH + ' list associations User={} Cluster={} Account={} Format=Cluster,Account,User,QOS -P' -SLURM_CMD_LIST_ACCOUNTS = SLURM_SACCTMGR_PATH + ' list associations User={} Cluster={} Format=Account -Pn' -SLURM_CMD_CHECK_DEFAULT_ACCOUNT = SLURM_SACCTMGR_PATH + ' show user User={} Cluster={} Format=DefaultAccount -Pn' -SLURM_CMD_CHANGE_DEFAULT_ACCOUNT = SLURM_SACCTMGR_PATH + ' -Q -i modify user User={} where Cluster={} set DefaultAccount={}' -SLURM_CMD_BLOCK_ACCOUNT = SLURM_SACCTMGR_PATH + ' -Q -i modify account {} where Cluster={} set GrpSubmitJobs=0' -SLURM_CMD_DUMP_CLUSTER = SLURM_SACCTMGR_PATH + ' dump {} file={}' +SLURM_CLUSTER_ATTRIBUTE_NAME = import_from_settings("SLURM_CLUSTER_ATTRIBUTE_NAME", "slurm_cluster") +SLURM_ACCOUNT_ATTRIBUTE_NAME = import_from_settings("SLURM_ACCOUNT_ATTRIBUTE_NAME", "slurm_account_name") +SLURM_SPECS_ATTRIBUTE_NAME = import_from_settings("SLURM_SPECS_ATTRIBUTE_NAME", "slurm_specs") +SLURM_USER_SPECS_ATTRIBUTE_NAME = import_from_settings("SLURM_USER_SPECS_ATTRIBUTE_NAME", "slurm_user_specs") +SLURM_SACCTMGR_PATH = import_from_settings("SLURM_SACCTMGR_PATH", "/usr/bin/sacctmgr") +SLURM_CMD_REMOVE_USER = SLURM_SACCTMGR_PATH + " -Q -i delete user where name={} cluster={} account={}" +SLURM_CMD_REMOVE_QOS = SLURM_SACCTMGR_PATH + " -Q -i modify user where name={} cluster={} account={} set {}" +SLURM_CMD_REMOVE_ACCOUNT = SLURM_SACCTMGR_PATH + " -Q -i delete account where name={} cluster={}" +SLURM_CMD_ADD_ACCOUNT = SLURM_SACCTMGR_PATH + " -Q -i create account name={} cluster={}" +SLURM_CMD_ADD_USER = SLURM_SACCTMGR_PATH + " -Q -i create user name={} cluster={} account={}" +SLURM_CMD_CHECK_ASSOCIATION = ( + SLURM_SACCTMGR_PATH + " list associations User={} Cluster={} Account={} Format=Cluster,Account,User,QOS -P" +) +SLURM_CMD_LIST_ACCOUNTS = SLURM_SACCTMGR_PATH + " list associations User={} Cluster={} Format=Account -Pn" +SLURM_CMD_CHECK_DEFAULT_ACCOUNT = SLURM_SACCTMGR_PATH + " show user User={} Cluster={} Format=DefaultAccount -Pn" +SLURM_CMD_CHANGE_DEFAULT_ACCOUNT = ( + SLURM_SACCTMGR_PATH + " -Q -i modify user User={} where Cluster={} set DefaultAccount={}" +) +SLURM_CMD_BLOCK_ACCOUNT = SLURM_SACCTMGR_PATH + " -Q -i modify account {} where Cluster={} set GrpSubmitJobs=0" +SLURM_CMD_DUMP_CLUSTER = SLURM_SACCTMGR_PATH + " dump {} file={}" logger = logging.getLogger(__name__) + class SlurmError(Exception): pass + def _run_slurm_cmd(cmd, noop=True): if noop: - logger.warn('NOOP - Slurm cmd: %s', cmd) + logger.warning("NOOP - Slurm cmd: %s", cmd) return try: result = subprocess.run(shlex.split(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) except subprocess.CalledProcessError as e: - if 'Nothing deleted' in str(e.stdout): + if "Nothing deleted" in str(e.stdout): # We tried to delete something that didn't exist. Don't throw error - logger.warn('Nothing to delete: %s', cmd) + logger.warning("Nothing to delete: %s", cmd) return e.stdout - if 'Nothing new added' in str(e.stdout): + if "Nothing new added" in str(e.stdout): # We tried to add something that already exists. Don't throw error - logger.warn('Nothing new to add: %s', cmd) + logger.warning("Nothing new to add: %s", cmd) return e.stdout - logger.error('Slurm command failed: %s', cmd) - err_msg = 'return_value={} stdout={} stderr={}'.format(e.returncode, e.stdout, e.stderr) + logger.error("Slurm command failed: %s", cmd) + err_msg = "return_value={} stdout={} stderr={}".format(e.returncode, e.stdout, e.stderr) raise SlurmError(err_msg) - logger.debug('Slurm cmd: %s', cmd) - logger.debug('Slurm cmd output: %s', result.stdout) + logger.debug("Slurm cmd: %s", cmd) + logger.debug("Slurm cmd output: %s", result.stdout) return result.stdout + def slurm_remove_assoc(user, cluster, account, noop=False): - #check default account + # check default account cmd = SLURM_CMD_CHECK_DEFAULT_ACCOUNT.format(shlex.quote(user), shlex.quote(cluster)) output = _run_slurm_cmd(cmd, noop=noop) default = "" @@ -66,7 +77,7 @@ def slurm_remove_assoc(user, cluster, account, noop=False): _remove_assoc(user=user, cluster=cluster, account=account, noop=noop) return - #get accounts + # get accounts cmd = SLURM_CMD_LIST_ACCOUNTS.format(shlex.quote(user), shlex.quote(cluster)) output = _run_slurm_cmd(cmd, noop=noop) accounts = [] @@ -77,57 +88,66 @@ def slurm_remove_assoc(user, cluster, account, noop=False): for userAccount in accounts: if userAccount != account: - cmd = SLURM_CMD_CHANGE_DEFAULT_ACCOUNT.format(shlex.quote(user), shlex.quote(cluster), shlex.quote(userAccount)) + cmd = SLURM_CMD_CHANGE_DEFAULT_ACCOUNT.format( + shlex.quote(user), shlex.quote(cluster), shlex.quote(userAccount) + ) _run_slurm_cmd(cmd, noop=noop) break - + _remove_assoc(user=user, cluster=cluster, account=account, noop=noop) - - + + def _remove_assoc(user, cluster, account, noop=False): cmd = SLURM_CMD_REMOVE_USER.format(shlex.quote(user), shlex.quote(cluster), shlex.quote(account)) _run_slurm_cmd(cmd, noop=noop) + def slurm_remove_qos(user, cluster, account, qos, noop=False): cmd = SLURM_CMD_REMOVE_QOS.format(shlex.quote(user), shlex.quote(cluster), shlex.quote(account), shlex.quote(qos)) _run_slurm_cmd(cmd, noop=noop) + def slurm_remove_account(cluster, account, noop=False): cmd = SLURM_CMD_REMOVE_ACCOUNT.format(shlex.quote(account), shlex.quote(cluster)) _run_slurm_cmd(cmd, noop=noop) + def slurm_add_assoc(user, cluster, account, specs=None, noop=False): if specs is None: specs = [] cmd = SLURM_CMD_ADD_USER.format(shlex.quote(user), shlex.quote(cluster), shlex.quote(account)) if len(specs) > 0: - cmd += ' ' + ' '.join(specs) + cmd += " " + " ".join(specs) _run_slurm_cmd(cmd, noop=noop) + def slurm_add_account(cluster, account, specs=None, noop=False): if specs is None: specs = [] cmd = SLURM_CMD_ADD_ACCOUNT.format(shlex.quote(account), shlex.quote(cluster)) if len(specs) > 0: - cmd += ' ' + ' '.join(specs) + cmd += " " + " ".join(specs) _run_slurm_cmd(cmd, noop=noop) + def slurm_block_account(cluster, account, noop=False): cmd = SLURM_CMD_BLOCK_ACCOUNT.format(shlex.quote(account), shlex.quote(cluster)) _run_slurm_cmd(cmd, noop=noop) + def slurm_check_assoc(user, cluster, account): cmd = SLURM_CMD_CHECK_ASSOCIATION.format(shlex.quote(user), shlex.quote(cluster), shlex.quote(account)) - output = _run_slurm_cmd(cmd, noop=False) + output = _run_slurm_cmd(cmd, noop=False) with StringIO(output.decode("UTF-8")) as fh: - reader = csv.DictReader(fh, delimiter='|') + reader = csv.DictReader(fh, delimiter="|") for row in reader: - if row['User'] == user and row['Account'] == account and row['Cluster'] == cluster: + if row["User"] == user and row["Account"] == account and row["Cluster"] == cluster: return True return False + def slurm_dump_cluster(cluster, fname, noop=False): cmd = SLURM_CMD_DUMP_CLUSTER.format(shlex.quote(cluster), shlex.quote(fname)) _run_slurm_cmd(cmd, noop=noop) diff --git a/coldfront/plugins/slurm/views.py b/coldfront/plugins/slurm/views.py new file mode 100644 index 0000000000..91d5b6a344 --- /dev/null +++ b/coldfront/plugins/slurm/views.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.contrib.auth.decorators import login_required +from django.db.models import Prefetch +from django.shortcuts import render + +from coldfront.core.allocation.models import Allocation, AllocationAttribute +from coldfront.core.utils.common import import_from_settings + +SLURM_SUBMISSION_INFO = import_from_settings("SLURM_SUBMISSION_INFO", ["account"]) +SLURM_DISPLAY_SHORT_OPTION_NAMES = import_from_settings("SLURM_DISPLAY_SHORT_OPTION_NAMES", False) +SLURM_SHORT_OPTION_NAMES = import_from_settings("SLURM_SHORT_OPTION_NAMES", {}) + + +@login_required +def get_full_slurm_help(request): + allocation_objs = ( + Allocation.objects.filter( + status__name__in=[ + "Active", + "Renewal Requested", + ], + allocationuser__user=request.user, + allocationuser__status__name="Active", + project__status__name="Active", + allocationattribute__allocation_attribute_type__name__in=["slurm_account_name", "slurm_specs"], + ) + .select_related("project") + .prefetch_related( + Prefetch( + lookup="allocationattribute_set", + queryset=AllocationAttribute.objects.filter(allocation_attribute_type__name="slurm_account_name"), + to_attr="slurm_account_name", + ), + Prefetch( + lookup="allocationattribute_set", + queryset=AllocationAttribute.objects.filter(allocation_attribute_type__name="slurm_specs"), + to_attr="slurm_specs", + ), + ) + ) + + slurm_info = {} + for allocation_obj in allocation_objs: + if not slurm_info.get(allocation_obj.project_id): + slurm_info[allocation_obj.project_id] = {"project_title": allocation_obj.project.title, "submit_info": {}} + slurm_info[allocation_obj.project_id]["submit_info"].update(get_slurm_info_from_allocation(allocation_obj)) + + return render(request, "slurm/full_slurm_help.html", {"slurm_info": slurm_info}) + + +@login_required +def get_slurm_help(request): + allocation_obj = ( + Allocation.objects.filter(pk=request.POST.get("allocation_pk")) + .prefetch_related( + Prefetch( + lookup="allocationattribute_set", + queryset=AllocationAttribute.objects.filter(allocation_attribute_type__name="slurm_account_name"), + to_attr="slurm_account_name", + ), + Prefetch( + lookup="allocationattribute_set", + queryset=AllocationAttribute.objects.filter(allocation_attribute_type__name="slurm_specs"), + to_attr="slurm_specs", + ), + ) + .first() + ) + slurm_info = get_slurm_info_from_allocation(allocation_obj) + return render(request, "slurm/slurm_help.html", {"slurm_info": slurm_info}) + + +def get_slurm_info_from_allocation(allocation_obj): + submit_options = {} + resource_obj = allocation_obj.get_parent_resource + resource_type = resource_obj.resource_type.name + if resource_type == "Cluster Partition": + cluster_obj = resource_obj.parent_resource + if "clusters" in SLURM_SUBMISSION_INFO: + submit_options["clusters"] = cluster_obj.resourceattribute_set.get( + resource_attribute_type__name="slurm_cluster" + ).value + if "partition" in SLURM_SUBMISSION_INFO: + submit_options["partition"] = resource_obj.name.lower() + elif resource_type != "Cluster": + return {} + + slurm_account = allocation_obj.slurm_account_name + if slurm_account: + if "account" in SLURM_SUBMISSION_INFO: + submit_options["account"] = slurm_account[0].value + + slurm_specs = resource_obj.resourceattribute_set.filter(resource_attribute_type__name="slurm_specs") + submit_options = get_slurm_info_from_slurm_specs(slurm_specs, submit_options) + slurm_specs = allocation_obj.slurm_specs + submit_options = get_slurm_info_from_slurm_specs(slurm_specs, submit_options) + + if SLURM_DISPLAY_SHORT_OPTION_NAMES: + submit_short_options = {} + for option, value in submit_options.items(): + short_option = SLURM_SHORT_OPTION_NAMES.get(option) + if short_option: + submit_short_options["-" + short_option] = value + else: + submit_short_options["--" + option] = value + + slurm_info = submit_short_options + else: + submit_long_options = {} + for option, value in submit_options.items(): + submit_long_options["--" + option] = value + + slurm_info = submit_long_options + + return {resource_obj.name: slurm_info} + + +def get_slurm_info_from_slurm_specs(slurm_specs, submit_options): + if slurm_specs: + specs = slurm_specs[0].value.replace("+", "").split(":") + for spec in specs: + spec_split = spec.split("=") + # Expanded attributes should be skipped + if len(spec_split) > 2: + continue + option, value = spec_split + option = option.lower() + if option in SLURM_SUBMISSION_INFO: + submit_options[option] = value + + return submit_options diff --git a/coldfront/plugins/system_monitor/__init__.py b/coldfront/plugins/system_monitor/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/system_monitor/__init__.py +++ b/coldfront/plugins/system_monitor/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/system_monitor/requirements.txt b/coldfront/plugins/system_monitor/requirements.txt deleted file mode 100644 index 5b8c55fa04..0000000000 --- a/coldfront/plugins/system_monitor/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -beautifulsoup4==4.7.1 diff --git a/coldfront/plugins/system_monitor/templates/system_monitor/system_monitor_div.html b/coldfront/plugins/system_monitor/templates/system_monitor/system_monitor_div.html index 6644df65c7..62b2027fe8 100644 --- a/coldfront/plugins/system_monitor/templates/system_monitor/system_monitor_div.html +++ b/coldfront/plugins/system_monitor/templates/system_monitor/system_monitor_div.html @@ -6,8 +6,8 @@

{{system_monitor_panel_title}}


{% if last_updated %}
- {% if SYSTEM_MONITOR_DISPLAY_XDMOD_LINK %} - {% endif %} + {% if settings.SYSTEM_MONITOR_DISPLAY_XDMOD_LINK %} + {% endif %}
@@ -18,10 +18,10 @@

{{system_monitor_panel_title}}

- {% if SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK %} + {% if settings.SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK %}
- {% if SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK %} - More status info + {% if settings.SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK %} + More status info
{% endif %}
{% endif %} @@ -36,6 +36,8 @@

{{system_monitor_panel_title}}

+{{ utilization_data|json_script:"utilization-data"}} +{{ jobs_data|json_script:"jobs-data" }} - - - - - - - - - - - - - - - - - - - - - - + {% django_htmx_script %} {% block title %}Welcome to ColdFront {% endblock %} - + {% include "su/is_su.html" %}
{% if user.is_authenticated %} @@ -57,9 +32,9 @@
- {% include 'common/messages.html' %} - {% block content %} - Content goes here! + {% include 'common/messages.html' %} + {% block content %} + Content goes here! {% endblock %}
diff --git a/coldfront/templates/common/bundle_snippet.html b/coldfront/templates/common/bundle_snippet.html new file mode 100644 index 0000000000..9e947f30e8 --- /dev/null +++ b/coldfront/templates/common/bundle_snippet.html @@ -0,0 +1,12 @@ +{% load static %} +{% load portal_tags %} +{% load django_vite %} +{% if settings.DJANGO_VITE.default.dev_mode %} + {% vite_hmr_client %} + {% vite_asset asset_name_ts %} +{% else %} + {% if asset_name_css %} + + {% endif %} + +{% endif %} diff --git a/coldfront/templates/common/footer.html b/coldfront/templates/common/footer.html index 83a5acd8f9..0f7df264b9 100644 --- a/coldfront/templates/common/footer.html +++ b/coldfront/templates/common/footer.html @@ -3,8 +3,8 @@
-
-

Powered by ColdFront Version {% get_version %} | GitHub

+
+

Powered by ColdFront Version {% get_version %} | GitHub

diff --git a/coldfront/templates/common/messages.html b/coldfront/templates/common/messages.html index 3f37f0abf0..7e27d15dbb 100644 --- a/coldfront/templates/common/messages.html +++ b/coldfront/templates/common/messages.html @@ -1,7 +1,7 @@ {% for message in messages %} diff --git a/coldfront/templates/common/navbar_admin.html b/coldfront/templates/common/navbar_admin.html index c5adc48304..318ef6395b 100644 --- a/coldfront/templates/common/navbar_admin.html +++ b/coldfront/templates/common/navbar_admin.html @@ -1,12 +1,16 @@ - \ No newline at end of file + diff --git a/coldfront/templates/common/navbar_nonadmin_staff.html b/coldfront/templates/common/navbar_nonadmin_staff.html index 8715967815..44c8f6269f 100644 --- a/coldfront/templates/common/navbar_nonadmin_staff.html +++ b/coldfront/templates/common/navbar_nonadmin_staff.html @@ -1,9 +1,10 @@ +{% load common_tags %} {% comment %} intentionally keeping "navbar-admin" (rather than renaming to navbar-staff), since existing templates use javascript to target it by ID {% endcomment %} -