diff --git a/.github/workflows/daily-csv-upload.yaml b/.github/workflows/daily-csv-upload.yaml index 724a19457..9cacfc3bf 100644 --- a/.github/workflows/daily-csv-upload.yaml +++ b/.github/workflows/daily-csv-upload.yaml @@ -31,3 +31,12 @@ jobs: cf_space: ${{ secrets.CF_REPORT_ENV }} cf_command: "run-task getgov-${{ secrets.CF_REPORT_ENV }} --command 'python manage.py generate_current_full_report' --name full" + - name: Generate and email domain-metadata.csv + uses: cloud-gov/cg-cli-tools@main + with: + cf_username: ${{ secrets[env.CF_USERNAME] }} + cf_password: ${{ secrets[env.CF_PASSWORD] }} + cf_org: cisa-dotgov + cf_space: ${{ secrets.CF_REPORT_ENV }} + cf_command: "run-task getgov-${{ secrets.CF_REPORT_ENV }} --command 'python manage.py email_current_metadata_report' --name metadata" + diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index e884c60a0..f7f4a0d65 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -22,6 +22,8 @@ jobs: || startsWith(github.head_ref, 'es/') || startsWith(github.head_ref, 'ky/') || startsWith(github.head_ref, 'backup/') + || startsWith(github.head_ref, 'meoward/') + || startsWith(github.head_ref, 'bob/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index 2033ee51c..825ab04d7 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -16,6 +16,8 @@ on: - stable - staging - development + - bob + - meoward - backup - ky - es diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index f8730c865..05eb963c3 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -16,6 +16,8 @@ on: options: - staging - development + - bob + - meoward - backup - ky - es diff --git a/docs/developer/README.md b/docs/developer/README.md index e28c57378..9f2e131b6 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -330,11 +330,12 @@ To associate a S3 instance to your sandbox, follow these steps: 3. Click `Services` on the application nav bar 4. Add a new service (plus symbol) 5. Click `Marketplace Service` -6. On the `Select the service` dropdown, select `s3` -7. Under the dropdown on `Select Plan`, select `basic-sandbox` -8. Under `Service Instance` enter `getgov-s3` for the name +6. For Space, put in your sandbox initials +7. On the `Select the service` dropdown, select `s3` +8. Under the dropdown on `Select Plan`, select `basic-sandbox` +9. Under `Service Instance` enter `getgov-s3` for the name and leave the other fields empty -See this [resource](https://cloud.gov/docs/services/s3/) for information on associating an S3 instance with your sandbox through the CLI. +See this [resource](https://cloud.gov/docs/services/s3/) for information on associating an S3 instance with your sandbox through the CLI. ### Testing your S3 instance locally To test the S3 bucket associated with your sandbox, you will need to add four additional variables to your `.env` file. These are as follows: diff --git a/docs/operations/runbooks/rotate_application_secrets.md b/docs/operations/runbooks/rotate_application_secrets.md index a776e60b8..1094b4ff7 100644 --- a/docs/operations/runbooks/rotate_application_secrets.md +++ b/docs/operations/runbooks/rotate_application_secrets.md @@ -117,3 +117,11 @@ You'll need to give the new certificate to the registry vendor _before_ rotating ## REGISTRY_HOSTNAME This is the hostname at which the registry can be found. + +## SECRET_METADATA_KEY + +This is the passphrase for the zipped and encrypted metadata email that is sent out daily. Reach out to product team members or leads with access to security passwords if the passcode is needed. + +To change the password, use a password generator to generate a password, then update the user credentials per the above instructions. Be sure to update the [KBDX](https://docs.google.com/document/d/1_BbJmjYZNYLNh4jJPPnUEG9tFCzJrOc0nMrZrnSKKyw) file in Google Drive with this password change. + + diff --git a/docs/operations/runbooks/update_python_dependencies.md b/docs/operations/runbooks/update_python_dependencies.md index b94c0f39f..468270d09 100644 --- a/docs/operations/runbooks/update_python_dependencies.md +++ b/docs/operations/runbooks/update_python_dependencies.md @@ -2,8 +2,8 @@ ======================== 1. Check the [Pipfile](../../../src/Pipfile) for pinned dependencies and manually adjust the version numbers - -2. Run +2. Run `docker-compose stop` to spin down the current containers and images so we can start afresh +3. Run cd src docker-compose run app bash -c "pipenv lock && pipenv requirements > requirements.txt" @@ -13,9 +13,9 @@ It is necessary to use `bash -c` because `run pipenv requirements` will not recognize that it is running non-interactively and will include garbage formatting characters. The requirements.txt is used by Cloud.gov. It is needed to work around a bug in the CloudFoundry buildpack version of Pipenv that breaks on installing from a git repository. -3. Change geventconnpool back to what it was originally within the Pipfile.lock and requirements.txt. +4. Change geventconnpool back to what it was originally within the Pipfile.lock and requirements.txt. This is done by either saving what it was originally or opening a PR and using that as a reference to undo changes to any mention of geventconnpool. Geventconnpool, when set as a requirement without the reference portion, is defaulting to get a commit from 2014 which then breaks the code, as we want the newest version from them. -4. (optional) Run `docker-compose stop` and `docker-compose build` to build a new image for local development with the updated dependencies. +5. Run `docker-compose build` to build a new image for local development with the updated dependencies. The reason for de-coupling the `build` and `lock` steps is to increase consistency between builds--a run of `build` will always get exactly the dependencies listed in `Pipfile.lock`, nothing more, nothing less. \ No newline at end of file diff --git a/ops/manifests/manifest-bob.yaml b/ops/manifests/manifest-bob.yaml new file mode 100644 index 000000000..f39d9e145 --- /dev/null +++ b/ops/manifests/manifest-bob.yaml @@ -0,0 +1,32 @@ +--- +applications: +- name: getgov-bob + buildpacks: + - python_buildpack + path: ../../src + instances: 1 + memory: 512M + stack: cflinuxfs4 + timeout: 180 + command: ./run.sh + health-check-type: http + health-check-http-endpoint: /health + health-check-invocation-timeout: 40 + env: + # Send stdout and stderr straight to the terminal without buffering + PYTHONUNBUFFERED: yup + # Tell Django where to find its configuration + DJANGO_SETTINGS_MODULE: registrar.config.settings + # Tell Django where it is being hosted + DJANGO_BASE_URL: https://getgov-bob.app.cloud.gov + # Tell Django how much stuff to log + DJANGO_LOG_LEVEL: INFO + # default public site location + GETGOV_PUBLIC_SITE_URL: https://get.gov + # Flag to disable/enable features in prod environments + IS_PRODUCTION: False + routes: + - route: getgov-bob.app.cloud.gov + services: + - getgov-credentials + - getgov-bob-database diff --git a/ops/manifests/manifest-meoward.yaml b/ops/manifests/manifest-meoward.yaml new file mode 100644 index 000000000..c47d9529d --- /dev/null +++ b/ops/manifests/manifest-meoward.yaml @@ -0,0 +1,32 @@ +--- +applications: +- name: getgov-meoward + buildpacks: + - python_buildpack + path: ../../src + instances: 1 + memory: 512M + stack: cflinuxfs4 + timeout: 180 + command: ./run.sh + health-check-type: http + health-check-http-endpoint: /health + health-check-invocation-timeout: 40 + env: + # Send stdout and stderr straight to the terminal without buffering + PYTHONUNBUFFERED: yup + # Tell Django where to find its configuration + DJANGO_SETTINGS_MODULE: registrar.config.settings + # Tell Django where it is being hosted + DJANGO_BASE_URL: https://getgov-meoward.app.cloud.gov + # Tell Django how much stuff to log + DJANGO_LOG_LEVEL: INFO + # default public site location + GETGOV_PUBLIC_SITE_URL: https://get.gov + # Flag to disable/enable features in prod environments + IS_PRODUCTION: False + routes: + - route: getgov-meoward.app.cloud.gov + services: + - getgov-credentials + - getgov-meoward-database diff --git a/src/Pipfile b/src/Pipfile index f24fbd550..9208fada5 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -29,6 +29,7 @@ django-login-required-middleware = "*" greenlet = "*" gevent = "*" fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"} +pyzipper="*" tblib = "*" [dev-packages] @@ -44,4 +45,4 @@ django-webtest = "*" types-cachetools = "*" boto3-mocking = "*" boto3-stubs = "*" -django-model2puml = "*" +django-model2puml = "*" \ No newline at end of file diff --git a/src/Pipfile.lock b/src/Pipfile.lock index 1c8eac0a0..4eb2c0fb3 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b5d93b1b9ccafc37019276a222957544bab3f1f46b5dab8a0f2ffc2e5c9e1678" + "sha256": "082a951f15bb26a28f2dca7e0840fdf61518b3d90c42d77a310f982344cbd1dc" }, "pipfile-spec": 6, "requires": {}, @@ -32,20 +32,20 @@ }, "boto3": { "hashes": [ - "sha256:8b3f5cc7fbedcbb22271c328039df8a6ab343001e746e0cdb24774c426cadcf8", - "sha256:f201b6a416f809283d554c652211eecec9fe3a52ed4063dab3f3e7aea7571d9c" + "sha256:300888f0c1b6f32f27f85a9aa876f50f46514ec619647af7e4d20db74d339714", + "sha256:b26928f9a21cf3649cea20a59061340f3294c6e7785ceb6e1a953eb8010dc3ba" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.54" + "version": "==1.34.56" }, "botocore": { "hashes": [ - "sha256:4061ff4be3efcf53547ebadf2c94d419dfc8be7beec24e9fa1819599ffd936fa", - "sha256:bf215d93e9d5544c593962780d194e74c6ee40b883d0b885e62ef35fc0ec01e5" + "sha256:bffeb71ab21d47d4ecf947d9bdb2fbd1b0bbd0c27742cea7cf0b77b701c41d9f", + "sha256:fff66e22a5589c2d58fba57d1d95c334ce771895e831f80365f6cff6453285ec" ], "markers": "python_version >= '3.8'", - "version": "==1.34.54" + "version": "==1.34.56" }, "cachetools": { "hashes": [ @@ -376,20 +376,20 @@ "django" ], "hashes": [ - "sha256:cc421ddb143fa30183568164755aa113a160e555cd19e97e664c478662032c24", - "sha256:feeaf28f17fd0499f9cd7c0fcf408c6d82c308e69e335eb92d09322fc9ed8138" + "sha256:069727a8f73d8ba8d033d3cd95c0da231d44f38f1da773bf076cef168d312ee8", + "sha256:e0bcfd41c718c07a7db422f9109e490746450da38793fe4ee197f397b9343435" ], "markers": "python_version >= '3.8'", - "version": "==10.3.0" + "version": "==11.0.0" }, "faker": { "hashes": [ - "sha256:117ce1a2805c1bc5ca753b3dc6f9d567732893b2294b827d3164261ee8f20267", - "sha256:458d93580de34403a8dec1e8d5e6be2fee96c4deca63b95d71df7a6a80a690de" + "sha256:2456d674f40bd51eb3acbf85221277027822e529a90cc826453d9a25dff932b1", + "sha256:ea6f784c40730de0f77067e49e78cdd590efb00bec3d33f577492262206c17fc" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==23.3.0" + "version": "==24.0.0" }, "fred-epplib": { "git": "https://github.com/cisagov/epplib.git", @@ -708,11 +708,11 @@ }, "marshmallow": { "hashes": [ - "sha256:20f53be28c6e374a711a16165fb22a8dc6003e3f7cda1285e3ca777b9193885b", - "sha256:e7997f83571c7fd476042c2c188e4ee8a78900ca5e74bd9c8097afa56624e9bd" + "sha256:4e65e9e0d80fc9e609574b9983cf32579f305c718afb30d7233ab818571768c3", + "sha256:f085493f79efb0644f270a9bf2892843142d80d7174bbbd2f3713f2a589dc633" ], "markers": "python_version >= '3.8'", - "version": "==3.21.0" + "version": "==3.21.1" }, "oic": { "hashes": [ @@ -994,6 +994,15 @@ "markers": "python_version >= '3.8'", "version": "==1.0.1" }, + "pyzipper": { + "hashes": [ + "sha256:0adca90a00c36a93fbe49bfa8c5add452bfe4ef85a1b8e3638739dd1c7b26bfc", + "sha256:6d097f465bfa47796b1494e12ea65d1478107d38e13bc56f6e58eedc4f6c1a87" + ], + "index": "pypi", + "markers": "python_version >= '3.4'", + "version": "==0.3.6" + }, "requests": { "hashes": [ "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", @@ -1186,12 +1195,12 @@ }, "boto3": { "hashes": [ - "sha256:8b3f5cc7fbedcbb22271c328039df8a6ab343001e746e0cdb24774c426cadcf8", - "sha256:f201b6a416f809283d554c652211eecec9fe3a52ed4063dab3f3e7aea7571d9c" + "sha256:300888f0c1b6f32f27f85a9aa876f50f46514ec619647af7e4d20db74d339714", + "sha256:b26928f9a21cf3649cea20a59061340f3294c6e7785ceb6e1a953eb8010dc3ba" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.54" + "version": "==1.34.56" }, "boto3-mocking": { "hashes": [ @@ -1204,28 +1213,28 @@ }, "boto3-stubs": { "hashes": [ - "sha256:7db5194e47f76e0010cd00b6ad9725db114d6a3fd04e52ceed3ef1181fe326bc", - "sha256:c7b2e8b99f4896cf1226df47d4badaaa8df7426008c96a428bf00205695669e9" + "sha256:627f8eca69d832581ee1676d39df099a2a2e3a86d6b3ebd21c81c5f11ed6a6fa", + "sha256:a87e7ecbab6235ec371b4363027e57483bca349a9cd5c891f40db81dadfa273e" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.54" + "version": "==1.34.56" }, "botocore": { "hashes": [ - "sha256:4061ff4be3efcf53547ebadf2c94d419dfc8be7beec24e9fa1819599ffd936fa", - "sha256:bf215d93e9d5544c593962780d194e74c6ee40b883d0b885e62ef35fc0ec01e5" + "sha256:bffeb71ab21d47d4ecf947d9bdb2fbd1b0bbd0c27742cea7cf0b77b701c41d9f", + "sha256:fff66e22a5589c2d58fba57d1d95c334ce771895e831f80365f6cff6453285ec" ], "markers": "python_version >= '3.8'", - "version": "==1.34.54" + "version": "==1.34.56" }, "botocore-stubs": { "hashes": [ - "sha256:958f0084322dc9e549f73151b686fa51b15858fb2b3a573b9f4367f073fff463", - "sha256:bcc35bfbd14d1261813681c40108f2ce85fdf082c15b0a04016d3c22dd93b73f" + "sha256:018e001e3add5eb1828ef444b45fb8c9faf695e08334031bf2d96853cd9af703", + "sha256:25468ba6983987b704b1856bb155f297f576e6d4a690b021ab0c7122889ba907" ], "markers": "python_version >= '3.8' and python_version < '4.0'", - "version": "==1.34.54" + "version": "==1.34.56" }, "click": { "hashes": [ diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 2e4ecaae5..c69c21192 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -58,6 +58,8 @@ services: - AWS_S3_SECRET_ACCESS_KEY - AWS_S3_REGION - AWS_S3_BUCKET_NAME + # File encryption credentials + - SECRET_ENCRYPT_METADATA stdin_open: true tty: true ports: diff --git a/src/registrar/admin.py b/src/registrar/admin.py index b321b9107..5f571ac73 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1,5 +1,6 @@ from datetime import date import logging +import copy from django import forms from django.db.models.functions import Concat, Coalesce @@ -850,18 +851,21 @@ class DomainInformationAdmin(ListHeaderAdmin): search_help_text = "Search by domain." fieldsets = [ - (None, {"fields": ["creator", "domain_request", "notes"]}), + (None, {"fields": ["creator", "submitter", "domain_request", "notes"]}), + (".gov domain", {"fields": ["domain"]}), + ("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}), + ("Background info", {"fields": ["anything_else"]}), ( "Type of organization", { "fields": [ "organization_type", + "is_election_board", + "federal_type", + "federal_agency", + "tribe_name", "federally_recognized_tribe", "state_recognized_tribe", - "tribe_name", - "federal_agency", - "federal_type", - "is_election_board", "about_your_organization", ] }, @@ -871,28 +875,15 @@ class DomainInformationAdmin(ListHeaderAdmin): { "fields": [ "organization_name", + "state_territory", "address_line1", "address_line2", "city", - "state_territory", "zipcode", "urbanization", ] }, ), - ("Authorizing official", {"fields": ["authorizing_official"]}), - (".gov domain", {"fields": ["domain"]}), - ("Your contact information", {"fields": ["submitter"]}), - ("Other employees from your organization?", {"fields": ["other_contacts"]}), - ( - "No other employees from your organization?", - {"fields": ["no_other_contacts_rationale"]}, - ), - ("Anything else?", {"fields": ["anything_else"]}), - ( - "Requirements for operating a .gov domain", - {"fields": ["is_policy_acknowledged"]}, - ), ] # Read only that we'll leverage for CISA Analysts @@ -1004,6 +995,8 @@ class DomainRequestAdmin(ListHeaderAdmin): if self.value() == "0": return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) + change_form_template = "django/admin/domain_application_change_form.html" + # Columns list_display = [ "requested_domain", @@ -1015,7 +1008,7 @@ class DomainRequestAdmin(ListHeaderAdmin): "custom_election_board", "city", "state_territory", - "created_at", + "submission_date", "submitter", "investigator", ] @@ -1052,18 +1045,34 @@ class DomainRequestAdmin(ListHeaderAdmin): search_help_text = "Search by domain or submitter." fieldsets = [ - (None, {"fields": ["status", "rejection_reason", "investigator", "creator", "approved_domain", "notes"]}), + ( + None, + { + "fields": [ + "status", + "rejection_reason", + "investigator", + "creator", + "submitter", + "approved_domain", + "notes", + ] + }, + ), + (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), + ("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}), + ("Background info", {"fields": ["purpose", "anything_else", "current_websites"]}), ( "Type of organization", { "fields": [ "organization_type", + "is_election_board", + "federal_type", + "federal_agency", + "tribe_name", "federally_recognized_tribe", "state_recognized_tribe", - "tribe_name", - "federal_agency", - "federal_type", - "is_election_board", "about_your_organization", ] }, @@ -1073,30 +1082,15 @@ class DomainRequestAdmin(ListHeaderAdmin): { "fields": [ "organization_name", + "state_territory", "address_line1", "address_line2", "city", - "state_territory", "zipcode", "urbanization", ] }, ), - ("Authorizing official", {"fields": ["authorizing_official"]}), - ("Current websites", {"fields": ["current_websites"]}), - (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), - ("Purpose of your domain", {"fields": ["purpose"]}), - ("Your contact information", {"fields": ["submitter"]}), - ("Other employees from your organization?", {"fields": ["other_contacts"]}), - ( - "No other employees from your organization?", - {"fields": ["no_other_contacts_rationale"]}, - ), - ("Anything else?", {"fields": ["anything_else"]}), - ( - "Requirements for operating a .gov domain", - {"fields": ["is_policy_acknowledged"]}, - ), ] # Read only that we'll leverage for CISA Analysts @@ -1328,7 +1322,13 @@ class DomainInformationInline(admin.StackedInline): model = models.DomainInformation - fieldsets = DomainInformationAdmin.fieldsets + fieldsets = copy.deepcopy(DomainInformationAdmin.fieldsets) + # remove .gov domain from fieldset + for index, (title, f) in enumerate(fieldsets): + if title == ".gov domain": + del fieldsets[index] + break + analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields # For each filter_horizontal, init in admin js extendFilterHorizontalWidgets # to activate the edit/delete/view buttons @@ -1471,6 +1471,20 @@ class DomainAdmin(ListHeaderAdmin): # Table ordering ordering = ["name"] + # Override for the delete confirmation page on the domain table (bulk delete action) + delete_selected_confirmation_template = "django/admin/domain_delete_selected_confirmation.html" + + def delete_view(self, request, object_id, extra_context=None): + """ + Custom delete_view to perform additional actions or customize the template. + """ + + # Set the delete template to a custom one + self.delete_confirmation_template = "django/admin/domain_delete_confirmation.html" + response = super().delete_view(request, object_id, extra_context=extra_context) + + return response + def changeform_view(self, request, object_id=None, form_url="", extra_context=None): """Custom changeform implementation to pass in context information""" if extra_context is None: @@ -1809,9 +1823,6 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): list_display = ("email", "requestor", "truncated_notes", "created_at") search_fields = ["email"] search_help_text = "Search by email." - list_filter = [ - "requestor", - ] readonly_fields = [ "requestor", ] diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index ff73acb65..4ed00c33f 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -29,20 +29,26 @@ function openInNewTab(el, removeAttribute = false){ */ (function (){ function createPhantomModalFormButtons(){ - let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"]'); + let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"].dja-form-placeholder'); form = document.querySelector("form") submitButtons.forEach((button) => { let input = document.createElement("input"); input.type = "submit"; - input.name = button.name; - input.value = button.value; + + if(button.name){ + input.name = button.name; + } + + if(button.value){ + input.value = button.value; + } + input.style.display = "none" // Add the hidden input to the form form.appendChild(input); button.addEventListener("click", () => { - console.log("clicking") input.click(); }) }) @@ -50,6 +56,61 @@ function openInNewTab(el, removeAttribute = false){ createPhantomModalFormButtons(); })(); + +/** An IIFE for DomainRequest to hook a modal to a dropdown option. + * This intentionally does not interact with createPhantomModalFormButtons() +*/ +(function (){ + function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){ + + // If these exist all at the same time, we're on the right page + if (linkClickedDisplaysModal && statusDropdown && statusDropdown.value){ + + // Set the previous value in the event the user cancels. + let previousValue = statusDropdown.value; + if (actionButton){ + + // Otherwise, if the confirmation buttion is pressed, set it to that + actionButton.addEventListener('click', function() { + // Revert the dropdown to its previous value + statusDropdown.value = valueToCheck; + }); + }else { + console.log("displayModalOnDropdownClick() -> Cancel button was null") + } + + // Add a change event listener to the dropdown. + statusDropdown.addEventListener('change', function() { + // Check if "Ineligible" is selected + if (this.value && this.value.toLowerCase() === valueToCheck) { + // Set the old value in the event the user cancels, + // or otherwise exists the dropdown. + statusDropdown.value = previousValue + + // Display the modal. + linkClickedDisplaysModal.click() + } + }); + } + } + + // When the status dropdown is clicked and is set to "ineligible", toggle a confirmation dropdown. + function hookModalToIneligibleStatus(){ + // Grab the invisible element that will hook to the modal. + // This doesn't technically need to be done with one, but this is simpler to manage. + let modalButton = document.getElementById("invisible-ineligible-modal-toggler") + let statusDropdown = document.getElementById("id_status") + + // Because the modal button does not have the class "dja-form-placeholder", + // it will not be affected by the createPhantomModalFormButtons() function. + let actionButton = document.querySelector('button[name="_set_domain_request_ineligible"]'); + let valueToCheck = "ineligible" + displayModalOnDropdownClick(modalButton, statusDropdown, actionButton, valueToCheck); + } + + hookModalToIneligibleStatus() +})(); + /** An IIFE for pages in DjangoAdmin which may need custom JS implementation. * Currently only appends target="_blank" to the domain_form object, * but this can be expanded. diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index dc67bc8b6..4f715de3c 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -143,6 +143,10 @@ h1, h2, h3, font-weight: font-weight('bold'); } +div#content > h2 { + font-size: 1.3rem; +} + .module h3 { padding: 0; color: var(--link-fg); @@ -299,3 +303,16 @@ input.admin-confirm-button { display: contents !important; } } + +.django-admin-modal .usa-prose ul > li { + list-style-type: inherit; + // Styling based off of the

styling in django admin + line-height: 1.5; + margin-bottom: 0; + margin-top: 0; + max-width: 68ex; +} + +.usa-summary-box__dhs-color { + color: $dhs-blue-70; +} diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 15799f91b..39074aab2 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -74,6 +74,9 @@ secret_aws_s3_key_id = secret("access_key_id", None) or secret("AWS_S3_ACCESS_KE secret_aws_s3_key = secret("secret_access_key", None) or secret("AWS_S3_SECRET_ACCESS_KEY", None) secret_aws_s3_bucket_name = secret("bucket", None) or secret("AWS_S3_BUCKET_NAME", None) +# Passphrase for the encrypted metadata email +secret_encrypt_metadata = secret("SECRET_ENCRYPT_METADATA", None) + secret_registry_cl_id = secret("REGISTRY_CL_ID") secret_registry_password = secret("REGISTRY_PASSWORD") secret_registry_cert = b64decode(secret("REGISTRY_CERT", "")) @@ -94,6 +97,7 @@ DEBUG = env_debug # Controls production specific feature toggles IS_PRODUCTION = env_is_production +SECRET_ENCRYPT_METADATA = secret_encrypt_metadata # Applications are modular pieces of code. # They are provided by Django, by third-parties, or by yourself. @@ -635,6 +639,8 @@ ALLOWED_HOSTS = [ "getgov-stable.app.cloud.gov", "getgov-staging.app.cloud.gov", "getgov-development.app.cloud.gov", + "getgov-bob.app.cloud.gov", + "getgov-meoward.app.cloud.gov", "getgov-backup.app.cloud.gov", "getgov-ky.app.cloud.gov", "getgov-es.app.cloud.gov", diff --git a/src/registrar/management/commands/email_current_metadata_report.py b/src/registrar/management/commands/email_current_metadata_report.py new file mode 100644 index 000000000..dcaf47b06 --- /dev/null +++ b/src/registrar/management/commands/email_current_metadata_report.py @@ -0,0 +1,105 @@ +"""Generates current-metadata.csv then uploads to S3 + sends email""" + +import logging +import os +import pyzipper + +from datetime import datetime + +from django.core.management import BaseCommand +from django.conf import settings +from registrar.utility import csv_export +from registrar.utility.s3_bucket import S3ClientHelper +from ...utility.email import send_templated_email + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = ( + "Generates and uploads a domain-metadata.csv file to our S3 bucket " + "which is based off of all existing Domains." + ) + + def add_arguments(self, parser): + """Add our two filename arguments.""" + parser.add_argument("--directory", default="migrationdata", help="Desired directory") + parser.add_argument( + "--checkpath", + default=True, + help="Flag that determines if we do a check for os.path.exists. Used for test cases", + ) + + def handle(self, **options): + """Grabs the directory then creates domain-metadata.csv in that directory""" + file_name = "domain-metadata.csv" + # Ensures a slash is added + directory = os.path.join(options.get("directory"), "") + check_path = options.get("checkpath") + + logger.info("Generating report...") + try: + self.email_current_metadata_report(directory, file_name, check_path) + except Exception as err: + # TODO - #1317: Notify operations when auto report generation fails + raise err + else: + logger.info(f"Success! Created {file_name} and successfully sent out an email!") + + def email_current_metadata_report(self, directory, file_name, check_path): + """Creates a current-metadata.csv file under the specified directory, + then uploads it to a AWS S3 bucket. This is done for resiliency + reasons in the event our application goes down and/or the email + cannot send -- we'll still be able to grab info from the S3 + instance""" + s3_client = S3ClientHelper() + file_path = os.path.join(directory, file_name) + + # Generate a file locally for upload + with open(file_path, "w") as file: + csv_export.export_data_type_to_csv(file) + + if check_path and not os.path.exists(file_path): + raise FileNotFoundError(f"Could not find newly created file at '{file_path}'") + + s3_client.upload_file(file_path, file_name) + + # Set zip file name + current_date = datetime.now().strftime("%m%d%Y") + current_filename = f"domain-metadata-{current_date}.zip" + + # Pre-set zip file name + encrypted_metadata_output = current_filename + + # Set context for the subject + current_date_str = datetime.now().strftime("%Y-%m-%d") + + # Encrypt the metadata + encrypted_metadata_in_bytes = self._encrypt_metadata( + s3_client.get_file(file_name), encrypted_metadata_output, str.encode(settings.SECRET_ENCRYPT_METADATA) + ) + + # Send the metadata file that is zipped + send_templated_email( + template_name="emails/metadata_body.txt", + subject_template_name="emails/metadata_subject.txt", + to_address=settings.DEFAULT_FROM_EMAIL, + context={"current_date_str": current_date_str}, + attachment_file=encrypted_metadata_in_bytes, + ) + + def _encrypt_metadata(self, input_file, output_file, password): + """Helper function for encrypting the attachment file""" + current_date = datetime.now().strftime("%m%d%Y") + current_filename = f"domain-metadata-{current_date}.csv" + # Using ZIP_DEFLATED bc it's a more common compression method supported by most zip utilities and faster + # We could also use compression=pyzipper.ZIP_LZMA if we are looking for smaller file size + with pyzipper.AESZipFile( + output_file, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES + ) as f_out: + f_out.setpassword(password) + f_out.writestr(current_filename, input_file) + with open(output_file, "rb") as file_data: + attachment_in_bytes = file_data.read() + return attachment_in_bytes diff --git a/src/registrar/migrations/0076_alter_domainrequest_current_websites_and_more.py b/src/registrar/migrations/0076_alter_domainrequest_current_websites_and_more.py new file mode 100644 index 000000000..b536f87c1 --- /dev/null +++ b/src/registrar/migrations/0076_alter_domainrequest_current_websites_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.10 on 2024-03-13 21:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0075_create_groups_v08"), + ] + + operations = [ + migrations.AlterField( + model_name="domainrequest", + name="current_websites", + field=models.ManyToManyField( + blank=True, related_name="current+", to="registrar.website", verbose_name="Current websites" + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="other_contacts", + field=models.ManyToManyField( + blank=True, + related_name="contact_domain_requests", + to="registrar.contact", + verbose_name="Other employees", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="other_contacts", + field=models.ManyToManyField( + blank=True, + related_name="contact_domain_requests_information", + to="registrar.contact", + verbose_name="Other employees", + ), + ), + ] diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 77a072ae9..f8f4db9c6 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -183,7 +183,7 @@ class DomainInformation(TimeStampedModel): "registrar.Contact", blank=True, related_name="contact_domain_requests_information", - verbose_name="contacts", + verbose_name="Other employees", ) no_other_contacts_rationale = models.TextField( diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 2282af726..e7378a880 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -505,7 +505,7 @@ class DomainRequest(TimeStampedModel): "registrar.Website", blank=True, related_name="current+", - verbose_name="websites", + verbose_name="Current websites", ) approved_domain = models.OneToOneField( @@ -551,7 +551,7 @@ class DomainRequest(TimeStampedModel): "registrar.Contact", blank=True, related_name="contact_domain_requests", - verbose_name="contacts", + verbose_name="Other employees", ) no_other_contacts_rationale = models.TextField( diff --git a/src/registrar/templates/django/admin/domain_application_change_form.html b/src/registrar/templates/django/admin/domain_application_change_form.html new file mode 100644 index 000000000..95392da1e --- /dev/null +++ b/src/registrar/templates/django/admin/domain_application_change_form.html @@ -0,0 +1,96 @@ +{% extends 'admin/change_form.html' %} +{% load i18n static %} + +{% block field_sets %} + {# Create an invisible tag so that we can use a click event to toggle the modal. #} + + {{ block.super }} +{% endblock %} + +{% block submit_buttons_bottom %} + {% comment %} + Modals behave very weirdly in django admin. + They tend to "strip out" any injected form elements, leaving only the main form. + In addition, USWDS handles modals by first destroying the element, then repopulating it toward the end of the page. + In effect, this means that the modal is not, and cannot, be surrounded by any form element at compile time. + + The current workaround for this is to use javascript to inject a hidden input, and bind submit of that + element to the click of the confirmation button within this modal. + + This is controlled by the class `dja-form-placeholder` on the button. + + In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions + of the application, so this means that it will briefly "populate", causing unintended visual effects. + {% endcomment %} + {# Create a modal for when a domain is marked as ineligible #} +

+
+
+ +
+

+ When a domain request is in ineligible status, the registrant's permissions within the registrar are restricted as follows: +

+
    +
  • They cannot edit the ineligible request or any other pending requests.
  • +
  • They cannot manage any of their approved domains.
  • +
  • They cannot initiate a new domain request.
  • +
+

+ The restrictions will not take effect until you “save” the changes for this domain request. + This action can be reversed, if needed. +

+

+ Domain: {{ original.requested_domain.name }} + {# Acts as a
#} +

+ New status: {{ original.DomainRequestStatus.INELIGIBLE|capfirst }} +

+
+ + +
+ +
+
+{{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index 67c5ac291..818522c8d 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -11,18 +11,15 @@
{% if original.state != original.State.DELETED %} - + Extend expiration date | {% endif %} {% if original.state == original.State.READY %} - + + Place hold + {% elif original.state == original.State.ON_HOLD %} {% endif %} @@ -30,7 +27,9 @@ | {% endif %} {% if original.state != original.State.DELETED %} - + + Remove from registry + {% endif %}
@@ -52,8 +51,10 @@ In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions of the application, so this means that it will briefly "populate", causing unintended visual effects. {% endcomment %} + + {# Create a modal for the _extend_expiration_date button #}
- + + + {# Create a modal for the _on_hold button #} +
+
+
+ +
+

+ When a domain is on hold: +

+
    +
  • The domain and its subdomains won’t resolve in DNS. Any infrastructure (like websites) will go offline.
  • +
  • The domain will still appear in the registrar / admin.
  • +
  • Domain managers won’t be able to edit the domain.
  • +
+

+ This action can be reversed, if needed. +

+

+ Domain: {{ original.name }} + {# Acts as a
#} +

+ New status: {{ original.State.ON_HOLD|capfirst }} +

+
+ + +
+ +
+
+ {# Create a modal for the _remove_domain button #} +
+
+
+ +
+

+ When a domain is removed from the registry: +

+
    +
  • The domain and its subdomains won’t resolve in DNS. Any infrastructure (like websites) will go offline.
  • +
  • The domain will still appear in the registrar / admin.
  • +
  • Domain managers won’t be able to edit the domain.
  • +
+

+ This action cannot be undone. +

+

+ Domain: {{ original.name }} + {# Acts as a
#} +

+ New status: {{ original.State.DELETED|capfirst }} +

+
+ + +
+ +
+
{{ block.super }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/registrar/templates/django/admin/domain_delete_confirmation.html b/src/registrar/templates/django/admin/domain_delete_confirmation.html new file mode 100644 index 000000000..5a9bef5b0 --- /dev/null +++ b/src/registrar/templates/django/admin/domain_delete_confirmation.html @@ -0,0 +1,27 @@ +{% extends 'admin/delete_confirmation.html' %} +{% load i18n static %} + +{% block content %} +
+
+

+ When a domain is deleted: +

+
+
    +
  • The domain will no longer appear in the registrar / admin.
  • +
  • It will be removed from the registry.
  • +
  • The domain and its subdomains won’t resolve in DNS.
  • +
  • Any infrastructure (like websites) will go offline.
  • +
+

You should probably remove this domain from the registry instead of deleting it.

+

This action cannot be undone.

+
+
+
+ {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/django/admin/domain_delete_selected_confirmation.html b/src/registrar/templates/django/admin/domain_delete_selected_confirmation.html new file mode 100644 index 000000000..3e0a32a4d --- /dev/null +++ b/src/registrar/templates/django/admin/domain_delete_selected_confirmation.html @@ -0,0 +1,28 @@ +{% extends 'admin/delete_selected_confirmation.html' %} +{% load i18n static %} + + +{% block content %} +
+
+

+ When a domain is deleted: +

+
+
    +
  • The domain will no longer appear in the registrar / admin.
  • +
  • It will be removed from the registry.
  • +
  • The domain and its subdomains won’t resolve in DNS.
  • +
  • Any infrastructure (like websites) will go offline.
  • +
+

You should probably remove these domains from the registry instead.

+

This action cannot be undone.

+
+
+
+ {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/emails/metadata_body.txt b/src/registrar/templates/emails/metadata_body.txt new file mode 100644 index 000000000..adf0a186c --- /dev/null +++ b/src/registrar/templates/emails/metadata_body.txt @@ -0,0 +1 @@ +An export of all .gov metadata. diff --git a/src/registrar/templates/emails/metadata_subject.txt b/src/registrar/templates/emails/metadata_subject.txt new file mode 100644 index 000000000..5fdece7ef --- /dev/null +++ b/src/registrar/templates/emails/metadata_subject.txt @@ -0,0 +1,2 @@ +Domain metadata - {{current_date_str}} + diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 24151261f..73bd08b62 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -97,7 +97,7 @@ def less_console_noise(output_stream=None): class GenericTestHelper(TestCase): """A helper class that contains various helper functions for TestCases""" - def __init__(self, admin, model=None, url=None, user=None, factory=None, **kwargs): + def __init__(self, admin, model=None, url=None, user=None, factory=None, client=None, **kwargs): """ Parameters: admin (ModelAdmin): The Django ModelAdmin instance associated with the model. @@ -112,6 +112,7 @@ class GenericTestHelper(TestCase): self.admin = admin self.model = model self.url = url + self.client = client def assert_table_sorted(self, o_index, sort_fields): """ @@ -147,9 +148,7 @@ class GenericTestHelper(TestCase): dummy_request.user = self.user # Mock a user request - middleware = SessionMiddleware(lambda req: req) - middleware.process_request(dummy_request) - dummy_request.session.save() + dummy_request = self._mock_user_request_for_factory(dummy_request) expected_sort_order = list(self.model.objects.order_by(*sort_fields)) @@ -160,6 +159,27 @@ class GenericTestHelper(TestCase): self.assertEqual(expected_sort_order, returned_sort_order) + def _mock_user_request_for_factory(self, request): + """Adds sessionmiddleware when using factory to associate session information""" + middleware = SessionMiddleware(lambda req: req) + middleware.process_request(request) + request.session.save() + return request + + def get_table_delete_confirmation_page(self, selected_across: str, index: str): + """ + Grabs the response for the delete confirmation page (generated from the actions toolbar). + selected_across and index must both be numbers encoded as str, e.g. "0" rather than 0 + """ + + response = self.client.post( + self.url, + {"action": "delete_selected", "select_across": selected_across, "index": index, "_selected_action": "23"}, + follow=True, + ) + print(f"what is the response? {response}") + return response + class MockUserLogin: def __init__(self, get_response): diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 2b85627ca..9666633e9 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -61,6 +61,16 @@ class TestDomainAdmin(MockEppLib, WebTest): self.factory = RequestFactory() self.app.set_user(self.superuser.username) self.client.force_login(self.superuser) + + # Contains some test tools + self.test_helper = GenericTestHelper( + factory=self.factory, + user=self.superuser, + admin=self.admin, + url=reverse("admin:registrar_domain_changelist"), + model=Domain, + client=self.client, + ) super().setUp() @skip("TODO for another ticket. This test case is grabbing old db data.") @@ -230,6 +240,35 @@ class TestDomainAdmin(MockEppLib, WebTest): ) mock_add_message.assert_has_calls([expected_call], 1) + def test_custom_delete_confirmation_page(self): + """Tests if we override the delete confirmation page for custom content""" + # Create a ready domain with a preset expiration date + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + + domain_change_page = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) + + self.assertContains(domain_change_page, "fake.gov") + # click the "Manage" link + confirmation_page = domain_change_page.click("Delete", index=0) + + content_slice = "When a domain is deleted:" + self.assertContains(confirmation_page, content_slice) + + def test_custom_delete_confirmation_page_table(self): + """Tests if we override the delete confirmation page for custom content on the table""" + # Create a ready domain + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + + # Get the index. The post expects the index to be encoded as a string + index = f"{domain.id}" + + # Simulate selecting a single record, then clicking "Delete selected domains" + response = self.test_helper.get_table_delete_confirmation_page("0", index) + + # Check that our content exists + content_slice = "When a domain is deleted:" + self.assertContains(response, content_slice) + def test_short_org_name_in_domains_list(self): """ Make sure the short name is displaying in admin on the list page @@ -309,6 +348,17 @@ class TestDomainAdmin(MockEppLib, WebTest): self.assertEqual(response.status_code, 200) self.assertContains(response, domain.name) self.assertContains(response, "Remove from registry") + + # The contents of the modal should exist before and after the post. + # Check for the header + self.assertContains(response, "Are you sure you want to remove this domain from the registry?") + + # Check for some of its body + self.assertContains(response, "When a domain is removed from the registry:") + + # Check for some of the button content + self.assertContains(response, "Yes, remove from registry") + # Test the info dialog request = self.factory.post( "/admin/registrar/domain/{}/change/".format(domain.pk), @@ -325,8 +375,60 @@ class TestDomainAdmin(MockEppLib, WebTest): extra_tags="", fail_silently=False, ) + + # The modal should still exist + self.assertContains(response, "Are you sure you want to remove this domain from the registry?") + self.assertContains(response, "When a domain is removed from the registry:") + self.assertContains(response, "Yes, remove from registry") + self.assertEqual(domain.state, Domain.State.DELETED) + def test_on_hold_is_successful_web_test(self): + """ + Scenario: Domain on_hold is successful through webtest + """ + with less_console_noise(): + domain = create_ready_domain() + + response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) + + # Check the contents of the modal + # Check for the header + self.assertContains(response, "Are you sure you want to place this domain on hold?") + + # Check for some of its body + self.assertContains(response, "When a domain is on hold:") + + # Check for some of the button content + self.assertContains(response, "Yes, place hold") + + # Grab the form to submit + form = response.forms["domain_form"] + + # Submit the form + response = form.submit("_place_client_hold") + + # Follow the response + response = response.follow() + + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Remove hold") + + # The modal should still exist + # Check for the header + self.assertContains(response, "Are you sure you want to place this domain on hold?") + + # Check for some of its body + self.assertContains(response, "When a domain is on hold:") + + # Check for some of the button content + self.assertContains(response, "Yes, place hold") + + # Web test has issues grabbing up to date data from the db, so we can test + # the returned view instead + self.assertContains(response, '
On hold
') + def test_deletion_ready_fsm_failure(self): """ Scenario: Domain deletion is unsuccessful @@ -1101,7 +1203,9 @@ class TestDomainRequestAdmin(MockEppLib): domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) # Create a mock request - request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + request = self.factory.post( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), follow=True + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client): # Modify the domain request's property @@ -1113,6 +1217,64 @@ class TestDomainRequestAdmin(MockEppLib): # Test that approved domain exists and equals requested domain self.assertEqual(domain_request.creator.status, "restricted") + def test_user_sets_restricted_status_modal(self): + """Tests the modal for when a user sets the status to restricted""" + with less_console_noise(): + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + p = "userpass" + self.client.login(username="staffuser", password=p) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + + # Check that the modal has the right content + # Check for the header + self.assertContains(response, "Are you sure you want to select ineligible status?") + + # Check for some of its body + self.assertContains(response, "When a domain request is in ineligible status") + + # Check for some of the button content + self.assertContains(response, "Yes, select ineligible status") + + # Create a mock request + request = self.factory.post( + "/admin/registrar/domainrequest{}/change/".format(domain_request.pk), follow=True + ) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + # Modify the domain request's property + domain_request.status = DomainRequest.DomainRequestStatus.INELIGIBLE + + # Use the model admin's save_model method + self.admin.save_model(request, domain_request, form=None, change=True) + + # Test that approved domain exists and equals requested domain + self.assertEqual(domain_request.creator.status, "restricted") + + # 'Get' to the domain request again + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + + # The modal should be unchanged + self.assertContains(response, "Are you sure you want to select ineligible status?") + self.assertContains(response, "When a domain request is in ineligible status") + self.assertContains(response, "Yes, select ineligible status") + def test_readonly_when_restricted_creator(self): with less_console_noise(): domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index a4807545b..19e695932 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -5,7 +5,8 @@ from unittest.mock import MagicMock from django.test import TestCase from .common import completed_domain_request, less_console_noise - +from datetime import datetime +from registrar.utility import email import boto3_mocking # type: ignore @@ -182,3 +183,32 @@ class TestEmails(TestCase): self.assertNotIn("Anything else", body) # spacing should be right between adjacent elements self.assertRegex(body, r"5557\n\n----") + + @boto3_mocking.patching + def test_send_email_with_attachment(self): + with boto3_mocking.clients.handler_for("ses", self.mock_client_class): + sender_email = "sender@example.com" + recipient_email = "recipient@example.com" + subject = "Test Subject" + body = "Test Body" + attachment_file = b"Attachment file content" + current_date = datetime.now().strftime("%m%d%Y") + current_filename = f"domain-metadata-{current_date}.zip" + + email.send_email_with_attachment( + sender_email, recipient_email, subject, body, attachment_file, self.mock_client + ) + # Assert that the `send_raw_email` method of the mocked SES client was called with the expected params + self.mock_client.send_raw_email.assert_called_once() + + # Get the args passed to the `send_raw_email` method + call_args = self.mock_client.send_raw_email.call_args[1] + + # Assert that the attachment filename is correct + self.assertEqual(call_args["RawMessage"]["Data"].count(f'filename="{current_filename}"'), 1) + + # Assert that the attachment content is encrypted + self.assertIn("Content-Type: application/octet-stream", call_args["RawMessage"]["Data"]) + self.assertIn("Content-Transfer-Encoding: base64", call_args["RawMessage"]["Data"]) + self.assertIn("Content-Disposition: attachment;", call_args["RawMessage"]["Data"]) + self.assertNotIn("Attachment file content", call_args["RawMessage"]["Data"]) diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 232453ad5..5f61181c7 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -2,8 +2,12 @@ import boto3 import logging +from datetime import datetime from django.conf import settings from django.template.loader import get_template +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText logger = logging.getLogger(__name__) @@ -15,7 +19,14 @@ class EmailSendingError(RuntimeError): pass -def send_templated_email(template_name: str, subject_template_name: str, to_address: str, bcc_address="", context={}): +def send_templated_email( + template_name: str, + subject_template_name: str, + to_address: str, + bcc_address="", + context={}, + attachment_file: str = None, +): """Send an email built from a template to one email address. template_name and subject_template_name are relative to the same template @@ -45,15 +56,50 @@ def send_templated_email(template_name: str, subject_template_name: str, to_addr destination["BccAddresses"] = [bcc_address] try: - ses_client.send_email( - FromEmailAddress=settings.DEFAULT_FROM_EMAIL, - Destination=destination, - Content={ - "Simple": { - "Subject": {"Data": subject}, - "Body": {"Text": {"Data": email_body}}, + if attachment_file is None: + ses_client.send_email( + FromEmailAddress=settings.DEFAULT_FROM_EMAIL, + Destination=destination, + Content={ + "Simple": { + "Subject": {"Data": subject}, + "Body": {"Text": {"Data": email_body}}, + }, }, - }, - ) + ) + else: + ses_client = boto3.client( + "ses", + region_name=settings.AWS_REGION, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=settings.BOTO_CONFIG, + ) + send_email_with_attachment( + settings.DEFAULT_FROM_EMAIL, to_address, subject, email_body, attachment_file, ses_client + ) except Exception as exc: raise EmailSendingError("Could not send SES email.") from exc + + +def send_email_with_attachment(sender, recipient, subject, body, attachment_file, ses_client): + # Create a multipart/mixed parent container + msg = MIMEMultipart("mixed") + msg["Subject"] = subject + msg["From"] = sender + msg["To"] = recipient + + # Add the text part + text_part = MIMEText(body, "plain") + msg.attach(text_part) + + # Add the attachment part + attachment_part = MIMEApplication(attachment_file) + # Adding attachment header + filename that the attachment will be called + current_date = datetime.now().strftime("%m%d%Y") + current_filename = f"domain-metadata-{current_date}.zip" + attachment_part.add_header("Content-Disposition", f'attachment; filename="{current_filename}"') + msg.attach(attachment_part) + + response = ses_client.send_raw_email(Source=sender, Destinations=[recipient], RawMessage={"Data": msg.as_string()}) + return response diff --git a/src/requirements.txt b/src/requirements.txt index 5696864bd..1db089f5a 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,8 +1,8 @@ -i https://pypi.python.org/simple annotated-types==0.6.0; python_version >= '3.8' asgiref==3.7.2; python_version >= '3.7' -boto3==1.34.54; python_version >= '3.8' -botocore==1.34.54; python_version >= '3.8' +boto3==1.34.56; python_version >= '3.8' +botocore==1.34.56; python_version >= '3.8' cachetools==5.3.3; python_version >= '3.7' certifi==2024.2.2; python_version >= '3.6' cfenv==0.5.3 @@ -22,8 +22,8 @@ django-fsm==2.8.1 django-login-required-middleware==0.9.0 django-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8' django-widget-tweaks==1.5.0; python_version >= '3.8' -environs[django]==10.3.0; python_version >= '3.8' -faker==23.3.0; python_version >= '3.8' +environs[django]==11.0.0; python_version >= '3.8' +faker==24.0.0; python_version >= '3.8' fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c furl==2.1.3 future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' @@ -35,7 +35,7 @@ jmespath==1.0.1; python_version >= '3.7' lxml==5.1.0; python_version >= '3.6' mako==1.3.2; python_version >= '3.8' markupsafe==2.1.5; python_version >= '3.7' -marshmallow==3.21.0; python_version >= '3.8' +marshmallow==3.21.1; python_version >= '3.8' oic==1.6.1; python_version ~= '3.7' orderedmultidict==1.0.1 packaging==23.2; python_version >= '3.7' @@ -49,6 +49,7 @@ pydantic-settings==2.2.1; python_version >= '3.8' pyjwkest==1.4.2 python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' python-dotenv==1.0.1; python_version >= '3.8' +pyzipper==0.3.6; python_version >= '3.4' requests==2.31.0; python_version >= '3.7' s3transfer==0.10.0; python_version >= '3.8' setuptools==69.1.1; python_version >= '3.8'