diff --git a/.github/workflows/delete-and-recreate-db.yaml b/.github/workflows/delete-and-recreate-db.yaml new file mode 100644 index 000000000..979f20826 --- /dev/null +++ b/.github/workflows/delete-and-recreate-db.yaml @@ -0,0 +1,90 @@ +# This workflow can be run from the CLI +# gh workflow run reset-db.yaml -f environment=ENVIRONMENT + +name: Delete and Recreate database +run-name: Delete and Recreate for ${{ github.event.inputs.environment }} + +on: + workflow_dispatch: + inputs: + environment: + type: choice + description: Which environment should we flush and re-load data for? + options: + - el + - ad + - ms + - ag + - litterbox + - hotgov + - cb + - bob + - meoward + - backup + - ky + - es + - nl + - rh + - za + - gd + - rb + - ko + - ab + - rjm + - dk + +jobs: + reset-db: + runs-on: ubuntu-latest + env: + CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME + CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD + DESTINATION_ENVIRONMENT: ${{ github.event.inputs.environment}} + steps: + - name: Delete and Recreate Database + env: + cf_username: ${{ secrets[env.CF_USERNAME] }} + cf_password: ${{ secrets[env.CF_PASSWORD] }} + run: | + # install cf cli and other tools + wget -q -O - https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key | sudo gpg --dearmor -o /usr/share/keyrings/cli.cloudfoundry.org.gpg + echo "deb [signed-by=/usr/share/keyrings/cli.cloudfoundry.org.gpg] https://packages.cloudfoundry.org/debian stable main" | sudo tee /etc/apt/sources.list.d/cloudfoundry-cli.list + + sudo apt-get update + sudo apt-get install cf8-cli + cf api api.fr.cloud.gov + cf auth "$cf_username" "$cf_password" + cf target -o cisa-dotgov -s $DESTINATION_ENVIRONMENT + + + + # unbind the service + cf unbind-service getgov-$DESTINATION_ENVIRONMENT getgov-$DESTINATION_ENVIRONMENT-database + #delete the service key + yes Y | cf delete-service-key getgov-$DESTINATION_ENVIRONMENT-database SERVICE_CONNECT + # delete the service + yes Y | cf delete-service getgov-$DESTINATION_ENVIRONMENT-database + # create it again + cf create-service aws-rds micro-psql getgov-$DESTINATION_ENVIRONMENT-database + # wait for it be created (up to 5 mins) + # this checks the creation cf service getgov-$DESTINATION_ENVIRONMENT-database + # the below command with check “status” line using cf service command mentioned above. if it says “create in progress” it will keep waiting otherwise the next steps fail + + timeout 480 bash -c "until cf service getgov-$DESTINATION_ENVIRONMENT-database | grep -q 'The service instance status is succeeded' + do + echo 'Database not up yet, waiting...' + sleep 30 + done" + + # rebind the service + cf bind-service getgov-$DESTINATION_ENVIRONMENT getgov-$DESTINATION_ENVIRONMENT-database + #restage the app or it will not connect to the database right for the next commands + cf restage getgov-$DESTINATION_ENVIRONMENT + # wait for the above command to finish + # if it is taking way to long and the annoying “instance starting” line that keeps repeating, then run following two commands in a separate window. This will interrupt the death loop where it keeps hitting an error with it failing health checks + # create the cache table and run migrations + cf run-task getgov-$DESTINATION_ENVIRONMENT --command 'python manage.py createcachetable' --name createcachetable + cf run-task getgov-$DESTINATION_ENVIRONMENT --wait --command 'python manage.py migrate' --name migrate + + # load fixtures + cf run-task getgov-$DESTINATION_ENVIRONMENT --wait --command 'python manage.py load' --name loaddata diff --git a/docs/developer/workflows/README.md b/docs/developer/workflows/README.md new file mode 100644 index 000000000..6cff81add --- /dev/null +++ b/docs/developer/workflows/README.md @@ -0,0 +1,7 @@ +# Workflows Docs + +======================== + +This directory contains files related to workflows + +Delete And Recreate Database is in [docs/ops](../workflows/delete-and-recreate-db.md/). \ No newline at end of file diff --git a/docs/developer/workflows/delete-and-recreate-db.md b/docs/developer/workflows/delete-and-recreate-db.md new file mode 100644 index 000000000..7b378ce47 --- /dev/null +++ b/docs/developer/workflows/delete-and-recreate-db.md @@ -0,0 +1,13 @@ +## Delete And Recreate Database + +This script destroys and recreates a database. This is another troubleshooting tool for issues with the database. + +1. unbinds the database +2. deletes it +3. recreates it +4. binds it back to the sandbox +5. runs migrations + +Addition Info in this slack thread: + +- [Slack thread](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1725495150772119) diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index cdef3dba7..8185922a4 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -914,7 +914,8 @@ Example (only requests): `./manage.py create_federal_portfolio --branch "executi | 3 | **both** | If True, runs parse_requests and parse_domains. | | 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. | | 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. | -| 6 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. | +| 6 | **add_managers** | If True, then the created portfolio will add all managers of the portfolio domains as members of the portfolio, including invited managers. | +| 7 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. | - Parameters #1-#2: Either `--agency_name` or `--branch` must be specified. Not both. - Parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them, diff --git a/ops/manifests/manifest-ab.yaml b/ops/manifests/manifest-ab.yaml index 3ca800392..f5a3e2c3c 100644 --- a/ops/manifests/manifest-ab.yaml +++ b/ops/manifests/manifest-ab.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-ab.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-ad.yaml b/ops/manifests/manifest-ad.yaml index 73d6f96ff..6975f9f50 100644 --- a/ops/manifests/manifest-ad.yaml +++ b/ops/manifests/manifest-ad.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-ad.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-ag.yaml b/ops/manifests/manifest-ag.yaml index 68d630f3e..192b58edb 100644 --- a/ops/manifests/manifest-ag.yaml +++ b/ops/manifests/manifest-ag.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-ag.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-backup.yaml b/ops/manifests/manifest-backup.yaml index ab9e36d68..194b6e91c 100644 --- a/ops/manifests/manifest-backup.yaml +++ b/ops/manifests/manifest-backup.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-backup.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-bob.yaml b/ops/manifests/manifest-bob.yaml index f39d9e145..7af7e1df5 100644 --- a/ops/manifests/manifest-bob.yaml +++ b/ops/manifests/manifest-bob.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-bob.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-cb.yaml b/ops/manifests/manifest-cb.yaml index b9be98d27..e08f800fa 100644 --- a/ops/manifests/manifest-cb.yaml +++ b/ops/manifests/manifest-cb.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-cb.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-development.yaml b/ops/manifests/manifest-development.yaml index 23558ba4c..957cb0227 100644 --- a/ops/manifests/manifest-development.yaml +++ b/ops/manifests/manifest-development.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-development.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-dk.yaml b/ops/manifests/manifest-dk.yaml index 071efb416..6afbe9321 100644 --- a/ops/manifests/manifest-dk.yaml +++ b/ops/manifests/manifest-dk.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-dk.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-el.yaml b/ops/manifests/manifest-el.yaml index 4c7d4d4e4..ee5673700 100644 --- a/ops/manifests/manifest-el.yaml +++ b/ops/manifests/manifest-el.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-el.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-es.yaml b/ops/manifests/manifest-es.yaml index 7fd19b7a0..f0fc73d7e 100644 --- a/ops/manifests/manifest-es.yaml +++ b/ops/manifests/manifest-es.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-es.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-gd.yaml b/ops/manifests/manifest-gd.yaml index 89a7c2169..5c4f83cc5 100644 --- a/ops/manifests/manifest-gd.yaml +++ b/ops/manifests/manifest-gd.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-gd.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-hotgov.yaml b/ops/manifests/manifest-hotgov.yaml index 70cc97ee7..2aa37817a 100644 --- a/ops/manifests/manifest-hotgov.yaml +++ b/ops/manifests/manifest-hotgov.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-hotgov.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-ko.yaml b/ops/manifests/manifest-ko.yaml index a69493f9b..adc3dcc89 100644 --- a/ops/manifests/manifest-ko.yaml +++ b/ops/manifests/manifest-ko.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-ko.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-ky.yaml b/ops/manifests/manifest-ky.yaml index f416d7385..292b0575c 100644 --- a/ops/manifests/manifest-ky.yaml +++ b/ops/manifests/manifest-ky.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-ky.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-litterbox.yaml b/ops/manifests/manifest-litterbox.yaml index ae899ef3a..e2ab5489c 100644 --- a/ops/manifests/manifest-litterbox.yaml +++ b/ops/manifests/manifest-litterbox.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-litterbox.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-meoward.yaml b/ops/manifests/manifest-meoward.yaml index c47d9529d..ba452684e 100644 --- a/ops/manifests/manifest-meoward.yaml +++ b/ops/manifests/manifest-meoward.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-meoward.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-ms.yaml b/ops/manifests/manifest-ms.yaml index ac46f5d92..0068dfa02 100644 --- a/ops/manifests/manifest-ms.yaml +++ b/ops/manifests/manifest-ms.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: DEBUG + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-nl.yaml b/ops/manifests/manifest-nl.yaml index d74174e7d..fbf3b0f5f 100644 --- a/ops/manifests/manifest-nl.yaml +++ b/ops/manifests/manifest-nl.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-nl.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-rb.yaml b/ops/manifests/manifest-rb.yaml index 570b49dde..02b099bdd 100644 --- a/ops/manifests/manifest-rb.yaml +++ b/ops/manifests/manifest-rb.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-rb.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-rh.yaml b/ops/manifests/manifest-rh.yaml index f44894ce8..abce35140 100644 --- a/ops/manifests/manifest-rh.yaml +++ b/ops/manifests/manifest-rh.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-rh.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-rjm.yaml b/ops/manifests/manifest-rjm.yaml index 048b44e95..b51db1b95 100644 --- a/ops/manifests/manifest-rjm.yaml +++ b/ops/manifests/manifest-rjm.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-rjm.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/manifests/manifest-stable.yaml b/ops/manifests/manifest-stable.yaml index 80c97339f..438a012a9 100644 --- a/ops/manifests/manifest-stable.yaml +++ b/ops/manifests/manifest-stable.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://manage.get.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: json # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Which OIDC provider to use diff --git a/ops/manifests/manifest-staging.yaml b/ops/manifests/manifest-staging.yaml index 38099cf17..7679b7248 100644 --- a/ops/manifests/manifest-staging.yaml +++ b/ops/manifests/manifest-staging.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-staging.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: json # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/ops/scripts/manifest-sandbox-template.yaml b/ops/scripts/manifest-sandbox-template.yaml index f0aee9664..ddd1860e1 100644 --- a/ops/scripts/manifest-sandbox-template.yaml +++ b/ops/scripts/manifest-sandbox-template.yaml @@ -21,6 +21,8 @@ applications: DJANGO_BASE_URL: https://getgov-ENVIRONMENT.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO + # tell django what log format to use: console or json. See settings.py for more details. + DJANGO_LOG_FORMAT: console # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 5ad6d0ce6..09bf8243e 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -79,6 +79,8 @@ services: - POSTGRES_DB=app - POSTGRES_USER=user - POSTGRES_PASSWORD=feedabee + ports: + - "5432:5432" node: build: diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 928ead442..2d2b90a5f 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -11,6 +11,7 @@ from django.db.models import ( Value, When, ) + from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect from registrar.models.federal_agency import FederalAgency @@ -24,7 +25,7 @@ from registrar.utility.admin_helpers import ( from django.conf import settings from django.contrib.messages import get_messages from django.contrib.admin.helpers import AdminForm -from django.shortcuts import redirect +from django.shortcuts import redirect, get_object_or_404 from django_fsm import get_available_FIELD_transitions, FSMField from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -1381,9 +1382,13 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): change_form_template = "django/admin/user_domain_role_change_form.html" + # Override for the delete confirmation page on the domain table (bulk delete action) + delete_selected_confirmation_template = "django/admin/user_domain_role_delete_selected_confirmation.html" + # Fixes a bug where non-superusers are redirected to the main page def delete_view(self, request, object_id, extra_context=None): """Custom delete_view implementation that specifies redirect behaviour""" + self.delete_confirmation_template = "django/admin/user_domain_role_delete_confirmation.html" response = super().delete_view(request, object_id, extra_context) if isinstance(response, HttpResponseRedirect) and not request.user.has_perm("registrar.full_access_permission"): @@ -1518,6 +1523,8 @@ class DomainInvitationAdmin(BaseInvitationAdmin): autocomplete_fields = ["domain"] change_form_template = "django/admin/domain_invitation_change_form.html" + # Override for the delete confirmation page on the domain table (bulk delete action) + delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html" # Select domain invitations to change -> Domain invitations def changelist_view(self, request, extra_context=None): @@ -1527,6 +1534,37 @@ class DomainInvitationAdmin(BaseInvitationAdmin): # Get the filtered values return super().changelist_view(request, extra_context=extra_context) + def change_view(self, request, object_id, form_url="", extra_context=None): + """Override the change_view to add the invitation obj for the change_form_object_tools template""" + + if extra_context is None: + extra_context = {} + + # Get the domain invitation object + invitation = get_object_or_404(DomainInvitation, id=object_id) + extra_context["invitation"] = invitation + + if request.method == "POST" and "cancel_invitation" in request.POST: + if invitation.status == DomainInvitation.DomainInvitationStatus.INVITED: + invitation.cancel_invitation() + invitation.save(update_fields=["status"]) + messages.success(request, _("Invitation canceled successfully.")) + + # Redirect back to the change view + return redirect(reverse("admin:registrar_domaininvitation_change", args=[object_id])) + + return super().change_view(request, object_id, form_url, extra_context) + + 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_invitation_delete_confirmation.html" + response = super().delete_view(request, object_id, extra_context=extra_context) + + return response + def save_model(self, request, obj, form, change): """ Override the save_model method. @@ -1535,6 +1573,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin): which will be successful if a single User exists for that email; otherwise, will just continue to create the invitation. """ + if not change: domain = obj.domain domain_org = getattr(domain.domain_info, "portfolio", None) diff --git a/src/registrar/assets/js/get-gov-reports.js b/src/registrar/assets/js/get-gov-reports.js deleted file mode 100644 index 92bba4a1f..000000000 --- a/src/registrar/assets/js/get-gov-reports.js +++ /dev/null @@ -1,130 +0,0 @@ -/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button, - * attach the seleted start and end dates to a url that'll trigger the view, and finally - * redirect to that url. - * - * This function also sets the start and end dates to match the url params if they exist -*/ -(function () { - // Function to get URL parameter value by name - function getParameterByName(name, url) { - if (!url) url = window.location.href; - name = name.replace(/[\[\]]/g, '\\$&'); - var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'), - results = regex.exec(url); - if (!results) return null; - if (!results[2]) return ''; - return decodeURIComponent(results[2].replace(/\+/g, ' ')); - } - - // Get the current date in the format YYYY-MM-DD - let currentDate = new Date().toISOString().split('T')[0]; - - // Default the value of the start date input field to the current date - let startDateInput = document.getElementById('start'); - - // Default the value of the end date input field to the current date - let endDateInput = document.getElementById('end'); - - let exportButtons = document.querySelectorAll('.exportLink'); - - if (exportButtons.length > 0) { - // Check if start and end dates are present in the URL - let urlStartDate = getParameterByName('start_date'); - let urlEndDate = getParameterByName('end_date'); - - // Set input values based on URL parameters or current date - startDateInput.value = urlStartDate || currentDate; - endDateInput.value = urlEndDate || currentDate; - - exportButtons.forEach((btn) => { - btn.addEventListener('click', function () { - // Get the selected start and end dates - let startDate = startDateInput.value; - let endDate = endDateInput.value; - let exportUrl = btn.dataset.exportUrl; - - // Build the URL with parameters - exportUrl += "?start_date=" + startDate + "&end_date=" + endDate; - - // Redirect to the export URL - window.location.href = exportUrl; - }); - }); - } - -})(); - - -/** An IIFE to initialize the analytics page -*/ -(function () { - function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) { - var canvas = document.getElementById(canvasId); - if (!canvas) { - return - } - - var ctx = canvas.getContext("2d"); - - var listOne = JSON.parse(canvas.getAttribute('data-list-one')); - var listTwo = JSON.parse(canvas.getAttribute('data-list-two')); - - var data = { - labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"], - datasets: [ - { - label: labelOne, - backgroundColor: "rgba(255, 99, 132, 0.2)", - borderColor: "rgba(255, 99, 132, 1)", - borderWidth: 1, - data: listOne, - }, - { - label: labelTwo, - backgroundColor: "rgba(75, 192, 192, 0.2)", - borderColor: "rgba(75, 192, 192, 1)", - borderWidth: 1, - data: listTwo, - }, - ], - }; - - var options = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'top', - }, - title: { - display: true, - text: title - } - }, - scales: { - y: { - beginAtZero: true, - }, - }, - }; - - new Chart(ctx, { - type: "bar", - data: data, - options: options, - }); - } - - function initComparativeColumnCharts() { - document.addEventListener("DOMContentLoaded", function () { - createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date"); - createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date"); - createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date"); - createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date"); - createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date"); - createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date"); - }); - }; - - initComparativeColumnCharts(); -})(); diff --git a/src/registrar/assets/src/js/getgov-admin/analytics.js b/src/registrar/assets/src/js/getgov-admin/analytics.js new file mode 100644 index 000000000..47bc81388 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/analytics.js @@ -0,0 +1,177 @@ +import { debounce } from '../getgov/helpers.js'; +import { getParameterByName } from './helpers-admin.js'; + +/** This function also sets the start and end dates to match the url params if they exist +*/ +function initAnalyticsExportButtons() { + // Get the current date in the format YYYY-MM-DD + let currentDate = new Date().toISOString().split('T')[0]; + + // Default the value of the start date input field to the current date + let startDateInput = document.getElementById('start'); + + // Default the value of the end date input field to the current date + let endDateInput = document.getElementById('end'); + + let exportButtons = document.querySelectorAll('.exportLink'); + + if (exportButtons.length > 0) { + // Check if start and end dates are present in the URL + let urlStartDate = getParameterByName('start_date'); + let urlEndDate = getParameterByName('end_date'); + + // Set input values based on URL parameters or current date + startDateInput.value = urlStartDate || currentDate; + endDateInput.value = urlEndDate || currentDate; + + exportButtons.forEach((btn) => { + btn.addEventListener('click', function () { + // Get the selected start and end dates + let startDate = startDateInput.value; + let endDate = endDateInput.value; + let exportUrl = btn.dataset.exportUrl; + + // Build the URL with parameters + exportUrl += "?start_date=" + startDate + "&end_date=" + endDate; + + // Redirect to the export URL + window.location.href = exportUrl; + }); + }); + } +}; + +/** + * Creates a diagonal stripe pattern for chart.js + * Inspired by https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns + * and https://github.com/ashiguruma/patternomaly + * @param {string} backgroundColor - Background color of the pattern + * @param {string} [lineColor="white"] - Color of the diagonal lines + * @param {boolean} [rightToLeft=false] - Direction of the diagonal lines + * @param {number} [lineGap=1] - Gap between lines + * @returns {CanvasPattern} A canvas pattern object for use with backgroundColor + */ +function createDiagonalPattern(backgroundColor, lineColor, rightToLeft=false, lineGap=1) { + // Define the canvas and the 2d context so we can draw on it + let shape = document.createElement("canvas"); + shape.width = 20; + shape.height = 20; + let context = shape.getContext("2d"); + + // Fill with specified background color + context.fillStyle = backgroundColor; + context.fillRect(0, 0, shape.width, shape.height); + + // Set stroke properties + context.strokeStyle = lineColor; + context.lineWidth = 2; + + // Rotate canvas for a right-to-left pattern + if (rightToLeft) { + context.translate(shape.width, 0); + context.rotate(90 * Math.PI / 180); + }; + + // First diagonal line + let halfSize = shape.width / 2; + context.moveTo(halfSize - lineGap, -lineGap); + context.lineTo(shape.width + lineGap, halfSize + lineGap); + + // Second diagonal line (x,y are swapped) + context.moveTo(-lineGap, halfSize - lineGap); + context.lineTo(halfSize + lineGap, shape.width + lineGap); + + context.stroke(); + return context.createPattern(shape, "repeat"); +} + +function createComparativeColumnChart(id, title, labelOne, labelTwo) { + var canvas = document.getElementById(id); + if (!canvas) { + return + } + + var ctx = canvas.getContext("2d"); + var listOne = JSON.parse(canvas.getAttribute('data-list-one')); + var listTwo = JSON.parse(canvas.getAttribute('data-list-two')); + + var data = { + labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"], + datasets: [ + { + label: labelOne, + backgroundColor: "rgba(255, 99, 132, 0.3)", + borderColor: "rgba(255, 99, 132, 1)", + borderWidth: 1, + data: listOne, + // Set this line style to be rightToLeft for visual distinction + backgroundColor: createDiagonalPattern('rgba(255, 99, 132, 0.3)', 'white', true) + }, + { + label: labelTwo, + backgroundColor: "rgba(75, 192, 192, 0.3)", + borderColor: "rgba(75, 192, 192, 1)", + borderWidth: 1, + data: listTwo, + backgroundColor: createDiagonalPattern('rgba(75, 192, 192, 0.3)', 'white') + }, + ], + }; + + var options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + title: { + display: true, + text: title + } + }, + scales: { + y: { + beginAtZero: true, + }, + }, + }; + return new Chart(ctx, { + type: "bar", + data: data, + options: options, + }); +} + +/** An IIFE to initialize the analytics page +*/ +export function initAnalyticsDashboard() { + const analyticsPageContainer = document.querySelector('.analytics-dashboard-charts'); + if (analyticsPageContainer) { + document.addEventListener("DOMContentLoaded", function () { + initAnalyticsExportButtons(); + + // Create charts and store each instance of it + const chartInstances = new Map(); + const charts = [ + { id: "managed-domains-chart", title: "Managed domains" }, + { id: "unmanaged-domains-chart", title: "Unmanaged domains" }, + { id: "deleted-domains-chart", title: "Deleted domains" }, + { id: "ready-domains-chart", title: "Ready domains" }, + { id: "submitted-requests-chart", title: "Submitted requests" }, + { id: "all-requests-chart", title: "All requests" } + ]; + charts.forEach(chart => { + if (chartInstances.has(chart.id)) chartInstances.get(chart.id).destroy(); + chartInstances.set(chart.id, createComparativeColumnChart(chart.id, chart.title, "Start Date", "End Date")); + }); + + // Add resize listener to each chart + window.addEventListener("resize", debounce(() => { + chartInstances.forEach((chart) => { + if (chart?.canvas) chart.resize(); + }); + }, 200)); + }); + } +}; diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-admin.js b/src/registrar/assets/src/js/getgov-admin/helpers-admin.js index ff618a67d..8055e29d3 100644 --- a/src/registrar/assets/src/js/getgov-admin/helpers-admin.js +++ b/src/registrar/assets/src/js/getgov-admin/helpers-admin.js @@ -22,3 +22,13 @@ export function addOrRemoveSessionBoolean(name, add){ sessionStorage.removeItem(name); } } + +export function getParameterByName(name, url) { + if (!url) url = window.location.href; + name = name.replace(/[\[\]]/g, '\\$&'); + var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'), + results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ''; + return decodeURIComponent(results[2].replace(/\+/g, ' ')); +} diff --git a/src/registrar/assets/src/js/getgov-admin/main.js b/src/registrar/assets/src/js/getgov-admin/main.js index 64be572b2..5c6de20ab 100644 --- a/src/registrar/assets/src/js/getgov-admin/main.js +++ b/src/registrar/assets/src/js/getgov-admin/main.js @@ -15,6 +15,7 @@ import { initDomainFormTargetBlankButtons } from './domain-form.js'; import { initDynamicPortfolioFields } from './portfolio-form.js'; import { initDynamicDomainInformationFields } from './domain-information-form.js'; import { initDynamicDomainFields } from './domain-form.js'; +import { initAnalyticsDashboard } from './analytics.js'; // General initModals(); @@ -41,3 +42,6 @@ initDynamicPortfolioFields(); // Domain information initDynamicDomainInformationFields(); + +// Analytics dashboard +initAnalyticsDashboard(); diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index c96677ebc..95723fc7e 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -128,7 +128,7 @@ export function initAddNewMemberPageListeners() { }); } else { // for admin users, the permissions are always the same - appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer); + appendPermissionInContainer('Domains', 'Viewer', permissionDetailsContainer); appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer); appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer); } diff --git a/src/registrar/assets/src/js/getgov/table-domain-requests.js b/src/registrar/assets/src/js/getgov/table-domain-requests.js index f667a96b5..8556b714f 100644 --- a/src/registrar/assets/src/js/getgov/table-domain-requests.js +++ b/src/registrar/assets/src/js/getgov/table-domain-requests.js @@ -116,10 +116,10 @@ export class DomainRequestsTable extends BaseTable { ${request.status} - -
+ +
- ${request.requested_domain ? request.requested_domain : 'New domain request'} diff --git a/src/registrar/assets/src/js/getgov/table-domains.js b/src/registrar/assets/src/js/getgov/table-domains.js index 3102484cf..1cf1fc2dd 100644 --- a/src/registrar/assets/src/js/getgov/table-domains.js +++ b/src/registrar/assets/src/js/getgov/table-domains.js @@ -56,13 +56,15 @@ export class DomainsTable extends BaseTable { ${markupForSuborganizationRow} - - - - ${domain.action_label} ${domain.name} - + + `; tbody.appendChild(row); diff --git a/src/registrar/assets/src/js/getgov/table-members.js b/src/registrar/assets/src/js/getgov/table-members.js index 75a7c29ac..29d140185 100644 --- a/src/registrar/assets/src/js/getgov/table-members.js +++ b/src/registrar/assets/src/js/getgov/table-members.js @@ -48,18 +48,6 @@ export class MembersTable extends BaseTable { // Get whether the logged in user has edit members permission const hasEditPermission = this.portfolioElement ? this.portfolioElement.getAttribute('data-has-edit-permission')==='True' : null; - let existingExtraActionsHeader = document.querySelector('.extra-actions-header'); - - if (hasEditPermission && !existingExtraActionsHeader) { - const extraActionsHeader = document.createElement('th'); - extraActionsHeader.setAttribute('id', 'extra-actions'); - extraActionsHeader.setAttribute('role', 'columnheader'); - extraActionsHeader.setAttribute('class', 'extra-actions-header width-5'); - extraActionsHeader.innerHTML = ` - Extra Actions`; - let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); - tableHeaderRow.appendChild(extraActionsHeader); - } return { 'hasAdditionalActions': hasEditPermission, 'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices @@ -121,15 +109,17 @@ export class MembersTable extends BaseTable { ${last_active.display_value} - - - - ${member.action_label} ${member.name} - + +
+ + + ${member.action_label} ${member.name} + + ${customTableOptions.hasAdditionalActions ? kebabHTML : ''} +
- ${customTableOptions.hasAdditionalActions ? ''+kebabHTML+'' : ''} `; tbody.appendChild(row); if (domainsHTML || permissionsHTML) { diff --git a/src/registrar/assets/src/sass/_theme/_admin.scss b/src/registrar/assets/src/sass/_theme/_admin.scss index 4f75fd2fb..a15d1eabe 100644 --- a/src/registrar/assets/src/sass/_theme/_admin.scss +++ b/src/registrar/assets/src/sass/_theme/_admin.scss @@ -498,6 +498,28 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too font-size: 13px; } +.object-tools li button { + font-family: Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif; + text-transform: none !important; + font-size: 14px !important; + display: block; + float: left; + padding: 3px 12px; + background: var(--object-tools-bg) !important; + color: var(--object-tools-fg); + font-weight: 400; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + border-radius: 15px; + cursor: pointer; + border: none; + line-height: 20px; + &:focus, &:hover{ + background: var(--object-tools-hover-bg) !important; + } +} + .module--custom { a { font-size: 13px; @@ -536,13 +558,18 @@ details.dja-detail-table { background-color: transparent; } + thead tr { + background-color: var(--darkened-bg); + } + td, th { padding-left: 12px; - border: none + border: none; + background-color: var(--darkened-bg); + color: var(--body-quiet-color); } thead > tr > th { - border-radius: 4px; border-top: none; border-bottom: none; } @@ -924,3 +951,34 @@ ul.add-list-reset { background-color: transparent !important; } } + +@media (min-width: 1080px) { + .analytics-dashboard-charts { + // Desktop layout - charts in top row, details in bottom row + display: grid; + gap: 2rem; + // Equal columns each gets 1/2 of the space + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: + "chart1 chart2" + "details1 details2" + "chart3 chart4" + "details3 details4" + "chart5 chart6" + "details5 details6"; + + .chart-1 { grid-area: chart1; } + .chart-2 { grid-area: chart2; } + .chart-3 { grid-area: chart3; } + .chart-4 { grid-area: chart4; } + .chart-5 { grid-area: chart5; } + .chart-6 { grid-area: chart6; } + .details-1 { grid-area: details1; } + .details-2 { grid-area: details2; } + .details-3 { grid-area: details3; } + .details-4 { grid-area: details4; } + .details-5 { grid-area: details5; } + .details-6 { grid-area: details6; } + } + +} diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index be3b89baf..9ca1355c3 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -1,7 +1,7 @@ @use "uswds-core" as *; @use "cisa_colors" as *; -$widescreen-max-width: 1920px; +$widescreen-max-width: 1536px; $widescreen-x-padding: 4.5rem; $hot-pink: #FFC3F9; @@ -46,12 +46,11 @@ body { background-color: color('gray-1'); } - .section-outlined { background-color: color('white'); border: 1px solid color('base-lighter'); border-radius: 4px; - padding: 0 units(4) units(3) units(2); + padding: 0 units(2) units(3) units(2); margin-top: units(3); &.margin-top-0 { @@ -275,3 +274,12 @@ abbr[title] { .width-quarter { width: 25%; } + +/* +NOTE: width: 3% basically forces a fit-content effect in the table. +Fit-content itself does not work. +*/ +.width--action-column { + width: 3%; + padding-right: 0px !important; +} diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 58250e85c..fa4c2d8dc 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -61,6 +61,7 @@ env_db_url = env.dj_db_url("DATABASE_URL") env_debug = env.bool("DJANGO_DEBUG", default=False) env_is_production = env.bool("IS_PRODUCTION", default=False) env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG") +env_log_format = env.str("DJANGO_LOG_FORMAT", "console") env_base_url: str = env.str("DJANGO_BASE_URL") env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "") env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox") @@ -106,6 +107,7 @@ DEBUG = env_debug # Controls production specific feature toggles IS_PRODUCTION = env_is_production SECRET_ENCRYPT_METADATA = secret_encrypt_metadata +BASE_URL = env_base_url # Applications are modular pieces of code. # They are provided by Django, by third-parties, or by yourself. @@ -492,12 +494,18 @@ class JsonServerFormatter(ServerFormatter): return json.dumps(log_entry) -# default to json formatted logs -server_formatter, console_formatter = "json.server", "json" - -# don't use json format locally, it makes logs hard to read in console +# If we're running locally we don't want json formatting if "localhost" in env_base_url: - server_formatter, console_formatter = "django.server", "verbose" + django_handlers = ["console"] +elif env_log_format == "json": + # in production we need everything to be logged as json so that log levels are parsed correctly + django_handlers = ["json"] +else: + # for non-production non-local environments: + # - send ERROR and above to json handler + # - send below ERROR to console handler with verbose formatting + # yes this is janky but it's the best we can do for now + django_handlers = ["split_console", "split_json"] LOGGING = { "version": 1, @@ -531,29 +539,52 @@ LOGGING = { "console": { "level": env_log_level, "class": "logging.StreamHandler", - "formatter": console_formatter, + "formatter": "verbose", + }, + # Special handlers for split logging case + "split_console": { + "level": env_log_level, + "class": "logging.StreamHandler", + "formatter": "verbose", + "filters": ["below_error"], + }, + "split_json": { + "level": "ERROR", + "class": "logging.StreamHandler", + "formatter": "json", }, "django.server": { "level": "INFO", "class": "logging.StreamHandler", - "formatter": server_formatter, + "formatter": "django.server", + }, + "json": { + "level": env_log_level, + "class": "logging.StreamHandler", + "formatter": "json", }, # No file logger is configured, # because containerized apps # do not log to the file system. }, + "filters": { + "below_error": { + "()": "django.utils.log.CallbackFilter", + "callback": lambda record: record.levelno < logging.ERROR, + } + }, # define loggers: these are "sinks" into which # messages are sent for processing "loggers": { # Django's generic logger "django": { - "handlers": ["console"], + "handlers": django_handlers, "level": "INFO", "propagate": False, }, # Django's template processor "django.template": { - "handlers": ["console"], + "handlers": django_handlers, "level": "INFO", "propagate": False, }, @@ -571,19 +602,19 @@ LOGGING = { }, # OpenID Connect logger "oic": { - "handlers": ["console"], + "handlers": django_handlers, "level": "INFO", "propagate": False, }, # Django wrapper for OpenID Connect "djangooidc": { - "handlers": ["console"], + "handlers": django_handlers, "level": "INFO", "propagate": False, }, # Our app! "registrar": { - "handlers": ["console"], + "handlers": django_handlers, "level": "DEBUG", "propagate": False, }, @@ -591,7 +622,7 @@ LOGGING = { # root logger catches anything, unless # defined by a more specific logger "root": { - "handlers": ["console"], + "handlers": django_handlers, "level": "INFO", }, } diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index a078c81ac..4e17b7fa1 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -68,19 +68,9 @@ def portfolio_permissions(request): "has_organization_requests_flag": False, "has_organization_members_flag": False, "is_portfolio_admin": False, - "has_domain_renewal_flag": False, } try: portfolio = request.session.get("portfolio") - - # These feature flags will display and doesn't depend on portfolio - portfolio_context.update( - { - "has_organization_feature_flag": True, - "has_domain_renewal_flag": request.user.has_domain_renewal_flag(), - } - ) - if portfolio: return { "has_view_portfolio_permission": request.user.has_view_portfolio_permission(portfolio), @@ -95,7 +85,6 @@ def portfolio_permissions(request): "has_organization_requests_flag": request.user.has_organization_requests_flag(), "has_organization_members_flag": request.user.has_organization_members_flag(), "is_portfolio_admin": request.user.is_portfolio_admin(portfolio), - "has_domain_renewal_flag": request.user.has_domain_renewal_flag(), } return portfolio_context diff --git a/src/registrar/fixtures/fixtures_users.py b/src/registrar/fixtures/fixtures_users.py index 876bc9fb5..fdaa1c135 100644 --- a/src/registrar/fixtures/fixtures_users.py +++ b/src/registrar/fixtures/fixtures_users.py @@ -171,6 +171,13 @@ class UserFixture: "email": "gina.summers@ecstech.com", "title": "Scrum Master", }, + { + "username": "89f2db87-87a2-4778-a5ea-5b27b585b131", + "first_name": "Jaxon", + "last_name": "Silva", + "email": "jaxon.silva@cisa.dhs.gov", + "title": "Designer", + }, ] STAFF = [ diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 2725224f1..9824ed68a 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -127,7 +127,7 @@ class BasePortfolioMemberForm(forms.ModelForm): domain_permissions = forms.ChoiceField( choices=[ (UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"), - (UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"), + (UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer"), ], widget=forms.RadioSelect, required=False, @@ -338,6 +338,24 @@ class BasePortfolioMemberForm(forms.ModelForm): and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles ) + def is_change(self) -> bool: + """ + Determines if the form has changed by comparing the initial data + with the submitted cleaned data. + + Returns: + bool: True if the form has changed, False otherwise. + """ + # Compare role values + previous_roles = set(self.initial.get("roles", [])) + new_roles = set(self.cleaned_data.get("roles", [])) + + # Compare additional permissions values + previous_permissions = set(self.initial.get("additional_permissions") or []) + new_permissions = set(self.cleaned_data.get("additional_permissions") or []) + + return previous_roles != new_roles or previous_permissions != new_permissions + class PortfolioMemberForm(BasePortfolioMemberForm): """ diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py index 4bc8f6715..d753d0ce8 100644 --- a/src/registrar/management/commands/create_federal_portfolio.py +++ b/src/registrar/management/commands/create_federal_portfolio.py @@ -5,9 +5,16 @@ import logging from django.core.management import BaseCommand, CommandError from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User +from registrar.models.domain import Domain +from registrar.models.domain_invitation import DomainInvitation +from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.models.user_domain_role import UserDomainRole +from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.generic_helper import normalize_string from django.db.models import F, Q +from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices + logger = logging.getLogger(__name__) @@ -21,6 +28,10 @@ class Command(BaseCommand): self.updated_portfolios = set() self.skipped_portfolios = set() self.failed_portfolios = set() + self.added_managers = set() + self.added_invitations = set() + self.skipped_invitations = set() + self.failed_managers = set() def add_arguments(self, parser): """Add command line arguments to create federal portfolios. @@ -38,6 +49,9 @@ class Command(BaseCommand): Optional (mutually exclusive with parse options): --both: Shorthand for using both --parse_requests and --parse_domains Cannot be used with --parse_requests or --parse_domains + + Optional: + --add_managers: Add all domain managers of the portfolio's domains to the organization. """ group = parser.add_mutually_exclusive_group(required=True) group.add_argument( @@ -64,23 +78,31 @@ class Command(BaseCommand): action=argparse.BooleanOptionalAction, help="Adds portfolio to both requests and domains", ) + parser.add_argument( + "--add_managers", + action=argparse.BooleanOptionalAction, + help="Add all domain managers of the portfolio's domains to the organization.", + ) parser.add_argument( "--skip_existing_portfolios", action=argparse.BooleanOptionalAction, help="Only add suborganizations to newly created portfolios, skip existing ones.", ) - def handle(self, **options): + def handle(self, **options): # noqa: C901 agency_name = options.get("agency_name") branch = options.get("branch") parse_requests = options.get("parse_requests") parse_domains = options.get("parse_domains") both = options.get("both") + add_managers = options.get("add_managers") skip_existing_portfolios = options.get("skip_existing_portfolios") if not both: - if not parse_requests and not parse_domains: - raise CommandError("You must specify at least one of --parse_requests or --parse_domains.") + if not (parse_requests or parse_domains or add_managers): + raise CommandError( + "You must specify at least one of --parse_requests, --parse_domains, or --add_managers." + ) else: if parse_requests or parse_domains: raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.") @@ -96,7 +118,6 @@ class Command(BaseCommand): ) else: raise CommandError(f"Cannot find '{branch}' federal agencies in our database.") - portfolios = [] for federal_agency in agencies: message = f"Processing federal agency '{federal_agency.agency}'..." @@ -107,6 +128,8 @@ class Command(BaseCommand): federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios ) portfolios.append(portfolio) + if add_managers: + self.add_managers_to_portfolio(portfolio) except Exception as exec: self.failed_portfolios.add(federal_agency) logger.error(exec) @@ -127,6 +150,26 @@ class Command(BaseCommand): display_as_str=True, ) + if add_managers: + TerminalHelper.log_script_run_summary( + self.added_managers, + self.failed_managers, + [], # can't skip managers, can only add or fail + log_header="----- MANAGERS ADDED -----", + debug=False, + display_as_str=True, + ) + + TerminalHelper.log_script_run_summary( + self.added_invitations, + [], + self.skipped_invitations, + log_header="----- INVITATIONS ADDED -----", + debug=False, + skipped_header="----- INVITATIONS SKIPPED (ALREADY EXISTED) -----", + display_as_str=True, + ) + # POST PROCESSING STEP: Remove the federal agency if it matches the portfolio name. # We only do this for started domain requests. if parse_requests or both: @@ -147,6 +190,73 @@ class Command(BaseCommand): ) self.post_process_started_domain_requests(agencies, portfolios) + def add_managers_to_portfolio(self, portfolio: Portfolio): + """ + Add all domain managers of the portfolio's domains to the organization. + This includes adding them to the correct group and creating portfolio invitations. + """ + logger.info(f"Adding managers for portfolio {portfolio}") + + # Fetch all domains associated with the portfolio + domains = Domain.objects.filter(domain_info__portfolio=portfolio) + domain_managers: set[UserDomainRole] = set() + + # Fetch all users with manager roles for the domains + # select_related means that a db query will not be occur when you do user_domain_role.user + # Its similar to a set or dict in that it costs slightly more upfront in exchange for perf later + user_domain_roles = UserDomainRole.objects.select_related("user").filter( + domain__in=domains, role=UserDomainRole.Roles.MANAGER + ) + domain_managers.update(user_domain_roles) + + invited_managers: set[str] = set() + + # Get the emails of invited managers + domain_invitations = DomainInvitation.objects.filter( + domain__in=domains, status=DomainInvitation.DomainInvitationStatus.INVITED + ).values_list("email", flat=True) + invited_managers.update(domain_invitations) + + for user_domain_role in domain_managers: + try: + # manager is a user id + user = user_domain_role.user + _, created = UserPortfolioPermission.objects.get_or_create( + portfolio=portfolio, + user=user, + defaults={"roles": [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]}, + ) + self.added_managers.add(user) + if created: + logger.info(f"Added manager '{user}' to portfolio '{portfolio}'") + else: + logger.info(f"Manager '{user}' already exists in portfolio '{portfolio}'") + except User.DoesNotExist: + self.failed_managers.add(user) + logger.debug(f"User '{user}' does not exist") + + for email in invited_managers: + self.create_portfolio_invitation(portfolio, email) + + def create_portfolio_invitation(self, portfolio: Portfolio, email: str): + """ + Create a portfolio invitation for the given email. + """ + _, created = PortfolioInvitation.objects.get_or_create( + portfolio=portfolio, + email=email, + defaults={ + "status": PortfolioInvitation.PortfolioInvitationStatus.INVITED, + "roles": [UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + }, + ) + if created: + self.added_invitations.add(email) + logger.info(f"Created portfolio invitation for '{email}' to portfolio '{portfolio}'") + else: + self.skipped_invitations.add(email) + logger.info(f"Found existing portfolio invitation for '{email}' to portfolio '{portfolio}'") + def post_process_started_domain_requests(self, agencies, portfolios): """ Removes duplicate organization data by clearing federal_agency when it matches the portfolio name. @@ -160,6 +270,7 @@ class Command(BaseCommand): # 2. Said portfolio (or portfolios) are only the ones specified at the start of the script. # 3. The domain request is in status "started". # Note: Both names are normalized so excess spaces are stripped and the string is lowercased. + domain_requests_to_update = DomainRequest.objects.filter( federal_agency__in=agencies, federal_agency__agency__isnull=False, diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 0f0b3f112..42310c3bb 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -9,6 +9,7 @@ from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignor from django.db import models, IntegrityError from django.utils import timezone from typing import Any +from registrar.models.domain_invitation import DomainInvitation from registrar.models.host import Host from registrar.models.host_ip import HostIP from registrar.utility.enums import DefaultEmail @@ -40,7 +41,6 @@ from .utility.time_stamped_model import TimeStampedModel from .public_contact import PublicContact from .user_domain_role import UserDomainRole -from waffle.decorators import flag_is_active logger = logging.getLogger(__name__) @@ -1171,12 +1171,16 @@ class Domain(TimeStampedModel, DomainHelper): """Return the display status of the domain.""" if self.is_expired() and (self.state != self.State.UNKNOWN): return "Expired" - elif flag_is_active(request, "domain_renewal") and self.is_expiring(): + elif self.is_expiring(): return "Expiring soon" elif self.state == self.State.UNKNOWN or self.state == self.State.DNS_NEEDED: return "DNS needed" return self.state.capitalize() + def active_invitations(self): + """Returns only the active invitations (those with status 'invited').""" + return self.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED) + def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type): """Maps the Epp contact representation to a PublicContact object. @@ -1583,7 +1587,7 @@ class Domain(TimeStampedModel, DomainHelper): # Given expired is not a physical state, but it is displayed as such, # We need custom logic to determine this message. help_text = "This domain has expired. Complete the online renewal process to maintain access." - elif flag_is_active(request, "domain_renewal") and self.is_expiring(): + elif self.is_expiring(): help_text = "This domain is expiring soon. Complete the online renewal process to maintain access." else: help_text = Domain.State.get_help_text(self.state) diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 8feeb0794..99febc92e 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -9,6 +9,10 @@ from .utility.portfolio_helper import ( UserPortfolioPermissionChoices, UserPortfolioRoleChoices, cleanup_after_portfolio_member_deletion, + get_domain_requests_display, + get_domains_display, + get_members_display, + get_role_display, validate_portfolio_invitation, ) # type: ignore from .utility.time_stamped_model import TimeStampedModel @@ -85,6 +89,60 @@ class PortfolioInvitation(TimeStampedModel): """ return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions) + @property + def role_display(self): + """ + Returns a human-readable display name for the user's role. + + Uses the `get_role_display` function to determine if the user is an "Admin", + "Basic" member, or has no role assigned. + + Returns: + str: The display name of the user's role. + """ + return get_role_display(self.roles) + + @property + def domains_display(self): + """ + Returns a string representation of the user's domain access level. + + Uses the `get_domains_display` function to determine whether the user has + "Viewer" access (can view all domains) or "Viewer, limited" access. + + Returns: + str: The display name of the user's domain permissions. + """ + return get_domains_display(self.roles, self.additional_permissions) + + @property + def domain_requests_display(self): + """ + Returns a string representation of the user's access to domain requests. + + Uses the `get_domain_requests_display` function to determine if the user + is a "Creator" (can create and edit requests), a "Viewer" (can only view requests), + or has "No access" to domain requests. + + Returns: + str: The display name of the user's domain request permissions. + """ + return get_domain_requests_display(self.roles, self.additional_permissions) + + @property + def members_display(self): + """ + Returns a string representation of the user's access to managing members. + + Uses the `get_members_display` function to determine if the user is a + "Manager" (can edit members), a "Viewer" (can view members), or has "No access" + to member management. + + Returns: + str: The display name of the user's member management permissions. + """ + return get_members_display(self.roles, self.additional_permissions) + @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) def retrieve(self): """When an invitation is retrieved, create the corresponding permission. diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 6f8ee499b..d5476ab9a 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -269,10 +269,7 @@ class User(AbstractUser): return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS) def is_portfolio_admin(self, portfolio): - return "Admin" in self.portfolio_role_summary(portfolio) - - def has_domain_renewal_flag(self): - return flag_is_active_for_user(self, "domain_renewal") + return self.has_edit_portfolio_permission(portfolio) def get_first_portfolio(self): permission = self.portfolio_permissions.first() @@ -280,49 +277,6 @@ class User(AbstractUser): return permission.portfolio return None - def portfolio_role_summary(self, portfolio): - """Returns a list of roles based on the user's permissions.""" - roles = [] - - # Define the conditions and their corresponding roles - conditions_roles = [ - (self.has_edit_portfolio_permission(portfolio), ["Admin"]), - ( - self.has_view_all_domains_portfolio_permission(portfolio) - and self.has_any_requests_portfolio_permission(portfolio) - and self.has_edit_request_portfolio_permission(portfolio), - ["View-only admin", "Domain requestor"], - ), - ( - self.has_view_all_domains_portfolio_permission(portfolio) - and self.has_any_requests_portfolio_permission(portfolio), - ["View-only admin"], - ), - ( - self.has_view_portfolio_permission(portfolio) - and self.has_edit_request_portfolio_permission(portfolio) - and self.has_any_domains_portfolio_permission(portfolio), - ["Domain requestor", "Domain manager"], - ), - ( - self.has_view_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio), - ["Domain requestor"], - ), - ( - self.has_view_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio), - ["Domain manager"], - ), - (self.has_view_portfolio_permission(portfolio), ["Member"]), - ] - - # Evaluate conditions and add roles - for condition, role_list in conditions_roles: - if condition: - roles.extend(role_list) - break - - return roles - def get_portfolios(self): return self.portfolio_permissions.all() diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 5378dc185..e077daa57 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -6,6 +6,10 @@ from registrar.models.utility.portfolio_helper import ( DomainRequestPermissionDisplay, MemberPermissionDisplay, cleanup_after_portfolio_member_deletion, + get_domain_requests_display, + get_domains_display, + get_members_display, + get_role_display, validate_user_portfolio_permission, ) from .utility.time_stamped_model import TimeStampedModel @@ -181,6 +185,60 @@ class UserPortfolioPermission(TimeStampedModel): # This is the same as portfolio_permissions & common_forbidden_perms. return portfolio_permissions.intersection(common_forbidden_perms) + @property + def role_display(self): + """ + Returns a human-readable display name for the user's role. + + Uses the `get_role_display` function to determine if the user is an "Admin", + "Basic" member, or has no role assigned. + + Returns: + str: The display name of the user's role. + """ + return get_role_display(self.roles) + + @property + def domains_display(self): + """ + Returns a string representation of the user's domain access level. + + Uses the `get_domains_display` function to determine whether the user has + "Viewer" access (can view all domains) or "Viewer, limited" access. + + Returns: + str: The display name of the user's domain permissions. + """ + return get_domains_display(self.roles, self.additional_permissions) + + @property + def domain_requests_display(self): + """ + Returns a string representation of the user's access to domain requests. + + Uses the `get_domain_requests_display` function to determine if the user + is a "Creator" (can create and edit requests), a "Viewer" (can only view requests), + or has "No access" to domain requests. + + Returns: + str: The display name of the user's domain request permissions. + """ + return get_domain_requests_display(self.roles, self.additional_permissions) + + @property + def members_display(self): + """ + Returns a string representation of the user's access to managing members. + + Uses the `get_members_display` function to determine if the user is a + "Manager" (can edit members), a "Viewer" (can view members), or has "No access" + to member management. + + Returns: + str: The display name of the user's member management permissions. + """ + return get_members_display(self.roles, self.additional_permissions) + def clean(self): """Extends clean method to perform additional validation, which can raise errors in django admin.""" super().clean() diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 5feae1cc1..03733237e 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -79,6 +79,100 @@ class MemberPermissionDisplay(StrEnum): NONE = "None" +def get_role_display(roles): + """ + Returns a user-friendly display name for a given list of user roles. + + - If the user has the ORGANIZATION_ADMIN role, return "Admin". + - If the user has the ORGANIZATION_MEMBER role, return "Basic". + - If the user has neither role, return "-". + + Args: + roles (list): A list of role strings assigned to the user. + + Returns: + str: The display name for the highest applicable role. + """ + if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles: + return "Admin" + elif UserPortfolioRoleChoices.ORGANIZATION_MEMBER in roles: + return "Basic" + else: + return "-" + + +def get_domains_display(roles, permissions): + """ + Determines the display name for a user's domain viewing permissions. + + - If the user has the VIEW_ALL_DOMAINS permission, return "Viewer". + - Otherwise, return "Viewer, limited". + + Args: + roles (list): A list of role strings assigned to the user. + permissions (list): A list of additional permissions assigned to the user. + + Returns: + str: A string representing the user's domain viewing access. + """ + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions) + if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions: + return "Viewer" + else: + return "Viewer, limited" + + +def get_domain_requests_display(roles, permissions): + """ + Determines the display name for a user's domain request permissions. + + - If the user has the EDIT_REQUESTS permission, return "Creator". + - If the user has the VIEW_ALL_REQUESTS permission, return "Viewer". + - Otherwise, return "No access". + + Args: + roles (list): A list of role strings assigned to the user. + permissions (list): A list of additional permissions assigned to the user. + + Returns: + str: A string representing the user's domain request access level. + """ + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions) + if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions: + return "Creator" + elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions: + return "Viewer" + else: + return "No access" + + +def get_members_display(roles, permissions): + """ + Determines the display name for a user's member management permissions. + + - If the user has the EDIT_MEMBERS permission, return "Manager". + - If the user has the VIEW_MEMBERS permission, return "Viewer". + - Otherwise, return "No access". + + Args: + roles (list): A list of role strings assigned to the user. + permissions (list): A list of additional permissions assigned to the user. + + Returns: + str: A string representing the user's member management access level. + """ + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions) + if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions: + return "Manager" + elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions: + return "Viewer" + else: + return "No access" + + def validate_user_portfolio_permission(user_portfolio_permission): """ Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html index 7c1a09c78..fdebff22c 100644 --- a/src/registrar/templates/admin/analytics.html +++ b/src/registrar/templates/admin/analytics.html @@ -1,11 +1,24 @@ {% extends "admin/base_site.html" %} {% load static %} +{% load i18n %} {% block content_title %}

Registrar Analytics

{% endblock %} +{% block breadcrumbs %} +{% comment %} +Overrides the breadcrumb styles found in this file: +https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/base.html +{% endcomment %} + +{% endblock %} + {% block content %} -
+
@@ -82,7 +95,7 @@
-
    +
    -
    -
    - -

    Chart: Managed domains

    -

    {{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}

    -
    -
    -
    - -

    Chart: Unmanaged domains

    -

    {{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}

    -
    -
    -
    +
    + {% comment %} Managed/Unmanaged domains {% endcomment %} +
    + +

    Chart: Managed domains

    +

    {{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}

    +
    +
    +
    +
    + Details for managed domains +
    + {% include "admin/analytics_graph_table.html" with data=data property_name="managed_domains" %} +
    +
    +
    +
    + +

    Chart: Unmanaged domains

    +

    {{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}

    +
    +
    +
    +
    + Details for unmanaged domains +
    + {% include "admin/analytics_graph_table.html" with data=data property_name="unmanaged_domains" %} +
    +
    +
    -
    -
    - -

    Chart: Deleted domains

    -

    {{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}

    -
    -
    -
    - -

    Chart: Ready domains

    -

    {{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}

    -
    -
    -
    + {% comment %} Deleted/Ready domains {% endcomment %} +
    + +

    Chart: Deleted domains

    +

    {{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}

    +
    +
    +
    +
    + Details for deleted domains +
    + {% include "admin/analytics_graph_table.html" with data=data property_name="deleted_domains" %} +
    +
    +
    +
    + +

    Chart: Ready domains

    +

    {{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}

    +
    +
    +
    +
    + Details for ready domains +
    + {% include "admin/analytics_graph_table.html" with data=data property_name="ready_domains" %} +
    +
    +
    -
    -
    - -

    Chart: Submitted requests

    -

    {{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}

    -
    -
    -
    - -

    Chart: All requests

    -

    {{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}

    -
    -
    -
    + {% comment %} Requests {% endcomment %} +
    + +

    Chart: Submitted requests

    +

    {{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}

    +
    +
    +
    +
    + Details for submitted requests +
    + {% include "admin/analytics_graph_table.html" with data=data property_name="submitted_requests" %} +
    +
    +
    +
    + +

    Chart: All requests

    +

    {{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}

    +
    +
    +
    +
    + Details for all requests +
    + {% include "admin/analytics_graph_table.html" with data=data property_name="requests" %} +
    +
    +
    +
diff --git a/src/registrar/templates/admin/analytics_graph_table.html b/src/registrar/templates/admin/analytics_graph_table.html new file mode 100644 index 000000000..5f10da93a --- /dev/null +++ b/src/registrar/templates/admin/analytics_graph_table.html @@ -0,0 +1,26 @@ + + + + + + + + + + {% comment %} + This ugly notation is equivalent to data.property_name.start_date_count.index. + Or represented in the pure python way: data[property_name]["start_date_count"][index] + {% endcomment %} + {% with start_counts=data|get_item:property_name|get_item:"start_date_count" end_counts=data|get_item:property_name|get_item:"end_date_count" %} + {% for org_count_type in data.org_count_types %} + {% with index=forloop.counter %} + + + + + + {% endwith %} + {% endfor %} + {% endwith %} + +
TypeStart date {{ data.start_date }}End date {{ data.end_date }}
{{ org_count_type }}{{ start_counts|slice:index|last }}{{ end_counts|slice:index|last }}
diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index b80917bb2..e5b3604b4 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -22,7 +22,6 @@ - {% endblock %} diff --git a/src/registrar/templates/admin/change_form_object_tools.html b/src/registrar/templates/admin/change_form_object_tools.html index 66011a3c4..2f3d282ea 100644 --- a/src/registrar/templates/admin/change_form_object_tools.html +++ b/src/registrar/templates/admin/change_form_object_tools.html @@ -15,13 +15,28 @@ {% else %} {% endif %} {% endblock %} - diff --git a/src/registrar/templates/django/admin/domain_invitation_change_form.html b/src/registrar/templates/django/admin/domain_invitation_change_form.html index 6ce6ed0d1..699760fa8 100644 --- a/src/registrar/templates/django/admin/domain_invitation_change_form.html +++ b/src/registrar/templates/django/admin/domain_invitation_change_form.html @@ -11,4 +11,4 @@
{{ block.super }} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html new file mode 100644 index 000000000..215bf5ada --- /dev/null +++ b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html @@ -0,0 +1,16 @@ +{% extends 'admin/delete_confirmation.html' %} +{% load i18n static %} + +{% block content_subtitle %} + + {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html new file mode 100644 index 000000000..2e15347c1 --- /dev/null +++ b/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html @@ -0,0 +1,16 @@ +{% extends 'admin/delete_selected_confirmation.html' %} +{% load i18n static %} + +{% block content_subtitle %} + + {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html index d07e5abf4..6624be95d 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html @@ -10,7 +10,6 @@ Title Email Phone - Roles Action @@ -28,11 +27,6 @@ {% endif %} {{ member.user.phone }} - - {% for role in member.user|portfolio_role_summary:original %} - {{ role }} - {% endfor %} - {% if member.user.email %} diff --git a/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html b/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html new file mode 100644 index 000000000..171f4c3ea --- /dev/null +++ b/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html @@ -0,0 +1,13 @@ +{% extends 'admin/delete_confirmation.html' %} +{% load i18n static %} + +{% block content_subtitle %} + + {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html b/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html new file mode 100644 index 000000000..392d1aebc --- /dev/null +++ b/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html @@ -0,0 +1,13 @@ +{% extends 'admin/delete_selected_confirmation.html' %} +{% load i18n static %} + +{% block content_subtitle %} + + {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index 758c43366..57749f038 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -35,7 +35,7 @@ {# UNKNOWN domains would not have an expiration date and thus would show 'Expired' #} {% if domain.is_expired and domain.state != domain.State.UNKNOWN %} Expired - {% elif has_domain_renewal_flag and domain.is_expiring %} + {% elif domain.is_expiring %} Expiring soon {% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %} DNS needed @@ -46,17 +46,17 @@ {% if domain.get_state_help_text %}

- {% if has_domain_renewal_flag and domain.is_expired and is_domain_manager %} + {% if domain.is_expired and is_domain_manager %} This domain has expired, but it is still online. {% url 'domain-renewal' pk=domain.id as url %} Renew to maintain access. - {% elif has_domain_renewal_flag and domain.is_expiring and is_domain_manager %} + {% elif domain.is_expiring and is_domain_manager %} This domain will expire soon. {% url 'domain-renewal' pk=domain.id as url %} Renew to maintain access. - {% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %} + {% elif domain.is_expiring and is_portfolio_user %} This domain will expire soon. Contact one of the listed domain managers to renew the domain. - {% elif has_domain_renewal_flag and domain.is_expired and is_portfolio_user %} + {% elif domain.is_expired and is_portfolio_user %} This domain has expired, but it is still online. Contact one of the listed domain managers to renew the domain. {% else %} {{ domain.get_state_help_text }} diff --git a/src/registrar/templates/domain_sidebar.html b/src/registrar/templates/domain_sidebar.html index 5946b6859..3302a6a79 100644 --- a/src/registrar/templates/domain_sidebar.html +++ b/src/registrar/templates/domain_sidebar.html @@ -81,7 +81,7 @@ {% endwith %} - {% if has_domain_renewal_flag and is_domain_manager%} + {% if is_domain_manager%} {% if domain.is_expiring or domain.is_expired %} {% with url_name="domain-renewal" %} {% include "includes/domain_sidenav_item.html" with item_text="Renewal form" %} diff --git a/src/registrar/templates/emails/action_needed_reasons/bad_name.txt b/src/registrar/templates/emails/action_needed_reasons/bad_name.txt index ac563b549..40e5ed899 100644 --- a/src/registrar/templates/emails/action_needed_reasons/bad_name.txt +++ b/src/registrar/templates/emails/action_needed_reasons/bad_name.txt @@ -17,7 +17,7 @@ Domains should uniquely identify a government organization and be clear to the g ACTION NEEDED -First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. Once you submit your updated request, we’ll resume the adjudication process. +First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. <{{ manage_url }}> Once you submit your updated request, we’ll resume the adjudication process. If you have questions or want to discuss potential domain names, reply to this email. diff --git a/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt b/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt index ef05e17d7..40d068cd9 100644 --- a/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt +++ b/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt @@ -21,7 +21,7 @@ We expect a senior official to be someone in a role of significant, executive re ACTION NEEDED Reply to this email with a justification for naming {{ domain_request.senior_official.get_formatted_name }} as the senior official. If you have questions or comments, include those in your reply. -Alternatively, you can log in to the registrar and enter a different senior official for this domain request. Once you submit your updated request, we’ll resume the adjudication process. +Alternatively, you can log in to the registrar and enter a different senior official for this domain request. <{{ manage_url }}> Once you submit your updated request, we’ll resume the adjudication process. THANK YOU diff --git a/src/registrar/templates/emails/domain_invitation.txt b/src/registrar/templates/emails/domain_invitation.txt index a077bff26..270786a7a 100644 --- a/src/registrar/templates/emails/domain_invitation.txt +++ b/src/registrar/templates/emails/domain_invitation.txt @@ -4,7 +4,7 @@ Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first {{ requestor_email }} has invited you to manage: {% for domain in domains %}{{ domain.name }} {% endfor %} -To manage domain information, visit the .gov registrar . +To manage domain information, visit the .gov registrar <{{ manage_url }}>. ---------------------------------------------------------------- {% if not requested_user %} diff --git a/src/registrar/templates/emails/domain_manager_deleted_notification.txt b/src/registrar/templates/emails/domain_manager_deleted_notification.txt new file mode 100644 index 000000000..fbb1e47cc --- /dev/null +++ b/src/registrar/templates/emails/domain_manager_deleted_notification.txt @@ -0,0 +1,27 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi,{% if domain_manager and domain_manager.first_name %} {{ domain_manager.first_name }}.{% endif %} + +A domain manager was removed from {{ domain.name }}. + +REMOVED BY: {{ removed_by.email }} +REMOVED ON: {{ date }} +MANAGER REMOVED: {{ manager_removed.email }} + +---------------------------------------------------------------- + +WHY DID YOU RECEIVE THIS EMAIL? +You’re listed as a domain manager for {{ domain.name }}, so you’ll receive a notification whenever a domain manager is removed from that domain. +If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email. + +THANK YOU +.Gov helps the public identify official, trusted information. Thank you for using a .gov domain. + +---------------------------------------------------------------- + +The .gov team +Contact us: +Learn about .gov + +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency +(CISA) +{% endautoescape %} diff --git a/src/registrar/templates/emails/domain_manager_deleted_notification_subject.txt b/src/registrar/templates/emails/domain_manager_deleted_notification_subject.txt new file mode 100644 index 000000000..c84a20f18 --- /dev/null +++ b/src/registrar/templates/emails/domain_manager_deleted_notification_subject.txt @@ -0,0 +1 @@ +A domain manager was removed from {{ domain.name }} \ No newline at end of file diff --git a/src/registrar/templates/emails/domain_manager_notification.txt b/src/registrar/templates/emails/domain_manager_notification.txt index c253937e4..b5096a9d8 100644 --- a/src/registrar/templates/emails/domain_manager_notification.txt +++ b/src/registrar/templates/emails/domain_manager_notification.txt @@ -15,7 +15,7 @@ The person who received the invitation will become a domain manager once they lo associated with the invited email address. If you need to cancel this invitation or remove the domain manager, you can do that by going to -this domain in the .gov registrar . +this domain in the .gov registrar <{{ manage_url }}>. WHY DID YOU RECEIVE THIS EMAIL? diff --git a/src/registrar/templates/emails/domain_request_withdrawn.txt b/src/registrar/templates/emails/domain_request_withdrawn.txt index fbdf5b4f1..fe026027b 100644 --- a/src/registrar/templates/emails/domain_request_withdrawn.txt +++ b/src/registrar/templates/emails/domain_request_withdrawn.txt @@ -11,7 +11,7 @@ STATUS: Withdrawn ---------------------------------------------------------------- YOU CAN EDIT YOUR WITHDRAWN REQUEST -You can edit and resubmit this request by signing in to the registrar . +You can edit and resubmit this request by signing in to the registrar <{{ manage_url }}>. SOMETHING WRONG? diff --git a/src/registrar/templates/emails/portfolio_admin_addition_notification.txt b/src/registrar/templates/emails/portfolio_admin_addition_notification.txt index b8953aa67..9e6da3985 100644 --- a/src/registrar/templates/emails/portfolio_admin_addition_notification.txt +++ b/src/registrar/templates/emails/portfolio_admin_addition_notification.txt @@ -16,7 +16,7 @@ The person who received the invitation will become an admin once they log in to associated with the invited email address. If you need to cancel this invitation or remove the admin, you can do that by going to -the Members section for your organization . +the Members section for your organization <{{ manage_url }}>. WHY DID YOU RECEIVE THIS EMAIL? diff --git a/src/registrar/templates/emails/portfolio_admin_removal_notification.txt b/src/registrar/templates/emails/portfolio_admin_removal_notification.txt index 6a536aa49..bf0338c03 100644 --- a/src/registrar/templates/emails/portfolio_admin_removal_notification.txt +++ b/src/registrar/templates/emails/portfolio_admin_removal_notification.txt @@ -8,7 +8,7 @@ REMOVED BY: {{ requestor_email }} REMOVED ON: {{date}} ADMIN REMOVED: {{ removed_email_address }} -You can view this update by going to the Members section for your .gov organization . +You can view this update by going to the Members section for your .gov organization <{{ manage_url }}>. ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/portfolio_invitation.txt b/src/registrar/templates/emails/portfolio_invitation.txt index 775b74c7c..893da153d 100644 --- a/src/registrar/templates/emails/portfolio_invitation.txt +++ b/src/registrar/templates/emails/portfolio_invitation.txt @@ -3,7 +3,7 @@ Hi. {{ requestor_email }} has invited you to {{ portfolio.organization_name }}. -You can view this organization on the .gov registrar . +You can view this organization on the .gov registrar <{{ manage_url }}>. ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/portfolio_update.txt b/src/registrar/templates/emails/portfolio_update.txt new file mode 100644 index 000000000..aa13a9fb9 --- /dev/null +++ b/src/registrar/templates/emails/portfolio_update.txt @@ -0,0 +1,35 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %} + +Your permissions were updated in the .gov registrar. + +ORGANIZATION: {{ portfolio.organization_name }} +UPDATED BY: {{ requestor_email }} +UPDATED ON: {{ date }} +YOUR PERMISSIONS: {{ permissions.role_display }} + Domains - {{ permissions.domains_display }} + Domain requests - {{ permissions.domain_requests_display }} + Members - {{ permissions.members_display }} + +Your updated permissions are now active in the .gov registrar . + +---------------------------------------------------------------- + +SOMETHING WRONG? +If you have questions or concerns, reach out to the person who updated your +permissions, or reply to this email. + + +THANK YOU +.Gov helps the public identify official, trusted information. Thank you for using a .gov +domain. + +---------------------------------------------------------------- + +The .gov team +Contact us: +Learn about .gov + +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency +(CISA) +{% endautoescape %} diff --git a/src/registrar/templates/emails/portfolio_update_subject.txt b/src/registrar/templates/emails/portfolio_update_subject.txt new file mode 100644 index 000000000..2cd806a73 --- /dev/null +++ b/src/registrar/templates/emails/portfolio_update_subject.txt @@ -0,0 +1 @@ +Your permissions were updated in the .gov registrar \ No newline at end of file diff --git a/src/registrar/templates/emails/status_change_approved.txt b/src/registrar/templates/emails/status_change_approved.txt index 821e89e42..635b36cbd 100644 --- a/src/registrar/templates/emails/status_change_approved.txt +++ b/src/registrar/templates/emails/status_change_approved.txt @@ -8,7 +8,7 @@ REQUESTED BY: {{ domain_request.creator.email }} REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Approved -You can manage your approved domain on the .gov registrar . +You can manage your approved domain on the .gov registrar <{{ manage_url }}>. ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/submission_confirmation.txt b/src/registrar/templates/emails/submission_confirmation.txt index d9d01ec3e..afbde48d5 100644 --- a/src/registrar/templates/emails/submission_confirmation.txt +++ b/src/registrar/templates/emails/submission_confirmation.txt @@ -20,7 +20,7 @@ During our review, we’ll verify that: - You work at the organization and/or can make requests on its behalf - Your requested domain meets our naming requirements {% endif %} -We’ll email you if we have questions. We’ll also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. . +We’ll email you if we have questions. We’ll also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. <{{ manage_url }}>. NEED TO MAKE CHANGES? diff --git a/src/registrar/templates/emails/transition_domain_invitation.txt b/src/registrar/templates/emails/transition_domain_invitation.txt index b6773d9e9..14dd626dd 100644 --- a/src/registrar/templates/emails/transition_domain_invitation.txt +++ b/src/registrar/templates/emails/transition_domain_invitation.txt @@ -31,7 +31,7 @@ CHECK YOUR .GOV DOMAIN CONTACTS This is a good time to check who has access to your .gov domain{% if domains|length > 1 %}s{% endif %}. The admin, technical, and billing contacts listed for your domain{% if domains|length > 1 %}s{% endif %} in our old system also received this email. In our new registrar, these contacts are all considered “domain managers.” We no longer have the admin, technical, and billing roles, and you aren’t limited to three domain managers like in the old system. - 1. Once you have your Login.gov account, sign in to the new registrar at . + 1. Once you have your Login.gov account, sign in to the new registrar at <{{ manage_url }}>. 2. Click the “Manage” link next to your .gov domain, then click on “Domain managers” to see who has access to your domain. 3. If any of these users should not have access to your domain, let us know in a reply to this email. @@ -57,7 +57,7 @@ THANK YOU The .gov team .Gov blog -Domain management +Domain management <{{ manage_url }}}> Get.gov The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) diff --git a/src/registrar/templates/emails/update_to_approved_domain.txt b/src/registrar/templates/emails/update_to_approved_domain.txt index 99f86ea54..070096f62 100644 --- a/src/registrar/templates/emails/update_to_approved_domain.txt +++ b/src/registrar/templates/emails/update_to_approved_domain.txt @@ -8,7 +8,7 @@ UPDATED BY: {{user}} UPDATED ON: {{date}} INFORMATION UPDATED: {{changes}} -You can view this update in the .gov registrar . +You can view this update in the .gov registrar <{{ manage_url }}>. Get help with managing your .gov domain . diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 94cb4ea6d..3cf04a830 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -9,7 +9,7 @@ -{% if has_domain_renewal_flag and num_expiring_domains > 0 and has_any_domains_portfolio_permission %} +{% if num_expiring_domains > 0 and has_any_domains_portfolio_permission %}

@@ -75,7 +75,7 @@
- {% if has_domain_renewal_flag and num_expiring_domains > 0 and not portfolio %} + {% if num_expiring_domains > 0 and not portfolio %}
@@ -173,7 +173,6 @@ >Deleted
- {% if has_domain_renewal_flag %}
Expiring soon
- {% endif %}
diff --git a/src/registrar/templates/includes/member_domains_edit_table.html b/src/registrar/templates/includes/member_domains_edit_table.html index 0b8ff005a..fd63e5aa7 100644 --- a/src/registrar/templates/includes/member_domains_edit_table.html +++ b/src/registrar/templates/includes/member_domains_edit_table.html @@ -1,5 +1,3 @@ -{% load static %} - {% if member %} - + {% with label_text="Search all domains" item_name="edit-member-domains" aria_label_text="Member domains search component" %} + {% include "includes/search.html" %} + {% endwith %} @@ -85,7 +45,7 @@ member domains - Assigned domains + Assigned domains Domains diff --git a/src/registrar/templates/includes/member_domains_table.html b/src/registrar/templates/includes/member_domains_table.html index d7839e485..4e63fdbc3 100644 --- a/src/registrar/templates/includes/member_domains_table.html +++ b/src/registrar/templates/includes/member_domains_table.html @@ -1,5 +1,3 @@ -{% load static %} - {% if member %} -