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/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 d27a4849c..1290cd3b9 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
@@ -1382,9 +1383,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"):
@@ -1519,6 +1524,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):
@@ -1528,6 +1535,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.
@@ -1536,6 +1574,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
index 92bba4a1f..b82a5574f 100644
--- a/src/registrar/assets/js/get-gov-reports.js
+++ b/src/registrar/assets/js/get-gov-reports.js
@@ -1,3 +1,4 @@
+
/** 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.
@@ -58,6 +59,51 @@
/** An IIFE to initialize the analytics page
*/
(function () {
+
+ /**
+ * 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(canvasId, title, labelOne, labelTwo) {
var canvas = document.getElementById(canvasId);
if (!canvas) {
@@ -74,17 +120,20 @@
datasets: [
{
label: labelOne,
- backgroundColor: "rgba(255, 99, 132, 0.2)",
+ 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.2)",
+ 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')
},
],
};
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 {
{{ 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 %}
+
+
+
+ If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
+ their domain management privileges if they already have that role assigned. Go to the
+ User Domain Roles table
+ if you want to remove the user from a domain.
+
+ If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
+ their domain management privileges if they already have that role assigned. Go to the
+ User Domain Roles table
+ if you want to remove the user from a domain.
+
+ If you remove someone from a domain here, it won't trigger any emails when you click "save."
+
+
+
+ {{ 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/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 @@
{{url}}
-{% 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 %}
-
+
-
-
-
-
-
+ {% with label_text="Domains assigned to " %}
+ {% if member %}
+ {% with label_text=label_text|add:member.email item_name="member-domains" aria_label_text="Member domains search component" %}
+ {% include "includes/search.html" %}
+ {% endwith %}
+ {% else %}
+ {% with label_text=label_text|add:portfolio_invitation.email item_name="member-domains" aria_label_text="Member domains search component" %}
+ {% include "includes/search.html" %}
+ {% endwith %}
+ {% endif %}
+ {% endwith %}
diff --git a/src/registrar/templates/portfolio_base.html b/src/registrar/templates/portfolio_base.html
index 9f43c7251..ec7c0c22b 100644
--- a/src/registrar/templates/portfolio_base.html
+++ b/src/registrar/templates/portfolio_base.html
@@ -9,7 +9,7 @@
{# the entire logged in page goes here #}
-
+
{% block portfolio_content %}{% endblock %}
diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py
index d21678d58..ff73e6dc1 100644
--- a/src/registrar/templatetags/custom_filters.py
+++ b/src/registrar/templatetags/custom_filters.py
@@ -251,15 +251,6 @@ def is_members_subpage(path):
return get_url_name(path) in url_names
-@register.filter(name="portfolio_role_summary")
-def portfolio_role_summary(user, portfolio):
- """Returns the value of user.portfolio_role_summary"""
- if user and portfolio:
- return user.portfolio_role_summary(portfolio)
- else:
- return []
-
-
@register.filter(name="display_requesting_entity")
def display_requesting_entity(domain_request):
"""Workaround for a newline issue in .txt files (our emails) as if statements
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 8e04899cd..717b0c9fa 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -121,7 +121,7 @@ class TestFsmModelResource(TestCase):
fsm_field_mock.save.assert_not_called()
-class TestDomainInvitationAdmin(TestCase):
+class TestDomainInvitationAdmin(WebTest):
"""Tests for the DomainInvitationAdmin class as super user
Notes:
@@ -129,15 +129,27 @@ class TestDomainInvitationAdmin(TestCase):
tests have available superuser, client, and admin
"""
- def setUp(self):
+ # csrf checks do not work with WebTest.
+ # We disable them here. TODO for another ticket.
+ csrf_checks = False
+
+ @classmethod
+ def setUpClass(self):
+ super().setUpClass()
+ self.site = AdminSite()
self.factory = RequestFactory()
- self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
self.superuser = create_superuser()
+
+ def setUp(self):
+ super().setUp()
+ self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
self.domain = Domain.objects.create(name="example.com")
self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser)
DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser)
"""Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080")
+ self.client.force_login(self.superuser)
+ self.app.set_user(self.superuser.username)
def tearDown(self):
"""Delete all DomainInvitation objects"""
@@ -1072,6 +1084,50 @@ class TestDomainInvitationAdmin(TestCase):
self.assertEqual(DomainInvitation.objects.count(), 0)
self.assertEqual(PortfolioInvitation.objects.count(), 1)
+ @less_console_noise_decorator
+ def test_custom_delete_confirmation_page(self):
+ """Tests if custom alerts display on Domain Invitation delete page"""
+ self.client.force_login(self.superuser)
+ self.app.set_user(self.superuser.username)
+ domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
+ domain_invitation, _ = DomainInvitation.objects.get_or_create(domain=domain)
+
+ domain_invitation_change_page = self.app.get(
+ reverse("admin:registrar_domaininvitation_change", args=[domain_invitation.pk])
+ )
+
+ self.assertContains(domain_invitation_change_page, "domain-invitation-test.gov")
+ # click the "Delete" link
+ confirmation_page = domain_invitation_change_page.click("Delete", index=0)
+
+ custom_alert_content = "If you cancel the domain invitation here"
+ self.assertContains(confirmation_page, custom_alert_content)
+
+ @less_console_noise_decorator
+ def test_custom_selected_delete_confirmation_page(self):
+ """Tests if custom alerts display on Domain Invitation selected delete page from Domain Invitation table"""
+ domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
+ domain_invitation, _ = DomainInvitation.objects.get_or_create(domain=domain)
+
+ # Get the index. The post expects the index to be encoded as a string
+ index = f"{domain_invitation.id}"
+
+ test_helper = GenericTestHelper(
+ factory=self.factory,
+ user=self.superuser,
+ admin=self.admin,
+ url=reverse("admin:registrar_domaininvitation_changelist"),
+ model=Domain,
+ client=self.client,
+ )
+
+ # Simulate selecting a single record, then clicking "Delete selected domains"
+ response = test_helper.get_table_delete_confirmation_page("0", index)
+
+ # Check for custom alert message
+ custom_alert_content = "If you cancel the domain invitation here"
+ self.assertContains(response, custom_alert_content)
+
class TestUserPortfolioPermissionAdmin(TestCase):
"""Tests for the PortfolioInivtationAdmin class"""
@@ -2048,7 +2104,7 @@ class TestDomainInformationAdmin(TestCase):
self.test_helper.assert_table_sorted("-4", ("-creator__first_name", "-creator__last_name"))
-class TestUserDomainRoleAdmin(TestCase):
+class TestUserDomainRoleAdmin(WebTest):
"""Tests for the UserDomainRoleAdmin class as super user
Notes:
@@ -2075,6 +2131,8 @@ class TestUserDomainRoleAdmin(TestCase):
"""Setup environment for a mock admin user"""
super().setUp()
self.client = Client(HTTP_HOST="localhost:8080")
+ self.client.force_login(self.superuser)
+ self.app.set_user(self.superuser.username)
def tearDown(self):
"""Delete all Users, Domains, and UserDomainRoles"""
@@ -2237,6 +2295,48 @@ class TestUserDomainRoleAdmin(TestCase):
# We only need to check for the end of the HTML string
self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com", count=1)
+ @less_console_noise_decorator
+ def test_custom_delete_confirmation_page(self):
+ """Tests if custom alerts display on User Domain Role delete page"""
+ domain, _ = Domain.objects.get_or_create(name="user-domain-role-test.gov", state=Domain.State.READY)
+ domain_role, _ = UserDomainRole.objects.get_or_create(domain=domain, user=self.superuser)
+
+ domain_invitation_change_page = self.app.get(
+ reverse("admin:registrar_userdomainrole_change", args=[domain_role.pk])
+ )
+
+ self.assertContains(domain_invitation_change_page, "user-domain-role-test.gov")
+ # click the "Delete" link
+ confirmation_page = domain_invitation_change_page.click("Delete", index=0)
+
+ custom_alert_content = "If you remove someone from a domain here"
+ self.assertContains(confirmation_page, custom_alert_content)
+
+ @less_console_noise_decorator
+ def test_custom_selected_delete_confirmation_page(self):
+ """Tests if custom alerts display on selected delete page from User Domain Roles table"""
+ domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
+ domain_role, _ = UserDomainRole.objects.get_or_create(domain=domain, user=self.superuser)
+
+ # Get the index. The post expects the index to be encoded as a string
+ index = f"{domain_role.id}"
+
+ test_helper = GenericTestHelper(
+ factory=self.factory,
+ user=self.superuser,
+ admin=self.admin,
+ url=reverse("admin:registrar_userdomainrole_changelist"),
+ model=Domain,
+ client=self.client,
+ )
+
+ # Simulate selecting a single record, then clicking "Delete selected domains"
+ response = test_helper.get_table_delete_confirmation_page("0", index)
+
+ # Check for custom alert message
+ custom_alert_content = "If you remove someone from a domain here"
+ self.assertContains(response, custom_alert_content)
+
class TestListHeaderAdmin(TestCase):
"""Tests for the ListHeaderAdmin class as super user
diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py
index 867bf1b82..4cae9a9e0 100644
--- a/src/registrar/tests/test_admin_domain.py
+++ b/src/registrar/tests/test_admin_domain.py
@@ -12,6 +12,7 @@ from registrar.models import (
Domain,
DomainRequest,
DomainInformation,
+ DomainInvitation,
User,
Host,
Portfolio,
@@ -495,6 +496,107 @@ class TestDomainInformationInline(MockEppLib):
self.assertIn("poopy@gov.gov", domain_managers)
+class TestDomainInvitationAdmin(TestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.staffuser = create_user(email="staffdomainmanager@meoward.com", is_staff=True)
+ cls.site = AdminSite()
+ cls.admin = DomainAdmin(model=Domain, admin_site=cls.site)
+ cls.factory = RequestFactory()
+
+ def setUp(self):
+ self.client = Client(HTTP_HOST="localhost:8080")
+ self.client.force_login(self.staffuser)
+ super().setUp()
+
+ def test_successful_cancel_invitation_flow_in_admin(self):
+ """Testing canceling a domain invitation in Django Admin."""
+
+ # 1. Create a domain and assign staff user role + domain manager
+ domain = Domain.objects.create(name="cancelinvitationflowviaadmin.gov")
+ UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
+
+ # 2. Invite a domain manager to the above domain
+ invitation = DomainInvitation.objects.create(
+ email="inviteddomainmanager@meoward.com",
+ domain=domain,
+ status=DomainInvitation.DomainInvitationStatus.INVITED,
+ )
+
+ # 3. Go to the Domain Invitations list in /admin
+ domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
+ response = self.client.get(domain_invitation_list_url)
+ self.assertEqual(response.status_code, 200)
+
+ # 4. Go to the change view of that invitation and make sure you can see the button
+ domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
+ response = self.client.get(domain_invitation_change_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Cancel invitation")
+
+ # 5. Click the cancel invitation button
+ response = self.client.post(domain_invitation_change_url, {"cancel_invitation": "true"}, follow=True)
+
+ # 6. Make sure we're redirect back to the change view page in /admin
+ self.assertRedirects(response, domain_invitation_change_url)
+
+ # 7. Confirm cancellation confirmation message appears
+ expected_message = f"Invitation for {invitation.email} on {domain.name} is canceled"
+ self.assertContains(response, expected_message)
+
+ def test_no_cancel_invitation_button_in_retrieved_state(self):
+ """Shouldn't be able to see the "Cancel invitation" button if invitation is RETRIEVED state"""
+
+ # 1. Create a domain and assign staff user role + domain manager
+ domain = Domain.objects.create(name="retrieved.gov")
+ UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
+
+ # 2. Invite a domain manager to the above domain and NOT in invited state
+ invitation = DomainInvitation.objects.create(
+ email="retrievedinvitation@meoward.com",
+ domain=domain,
+ status=DomainInvitation.DomainInvitationStatus.RETRIEVED,
+ )
+
+ # 3. Go to the Domain Invitations list in /admin
+ domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
+ response = self.client.get(domain_invitation_list_url)
+ self.assertEqual(response.status_code, 200)
+
+ # 4. Go to the change view of that invitation and make sure you CANNOT see the button
+ domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
+ response = self.client.get(domain_invitation_change_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "Cancel invitation")
+
+ def test_no_cancel_invitation_button_in_canceled_state(self):
+ """Shouldn't be able to see the "Cancel invitation" button if invitation is CANCELED state"""
+
+ # 1. Create a domain and assign staff user role + domain manager
+ domain = Domain.objects.create(name="canceled.gov")
+ UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
+
+ # 2. Invite a domain manager to the above domain and NOT in invited state
+ invitation = DomainInvitation.objects.create(
+ email="canceledinvitation@meoward.com",
+ domain=domain,
+ status=DomainInvitation.DomainInvitationStatus.CANCELED,
+ )
+
+ # 3. Go to the Domain Invitations list in /admin
+ domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
+ response = self.client.get(domain_invitation_list_url)
+ self.assertEqual(response.status_code, 200)
+
+ # 4. Go to the change view of that invitation and make sure you CANNOT see the button
+ domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
+ response = self.client.get(domain_invitation_change_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "Cancel invitation")
+
+
class TestDomainAdminWithClient(TestCase):
"""Test DomainAdmin class as super user.
diff --git a/src/registrar/tests/test_email_invitations.py b/src/registrar/tests/test_email_invitations.py
index f07e2f2a7..391fbd445 100644
--- a/src/registrar/tests/test_email_invitations.py
+++ b/src/registrar/tests/test_email_invitations.py
@@ -920,6 +920,7 @@ class TestSendPortfolioMemberPermissionUpdateEmail(unittest.TestCase):
"portfolio": permissions.portfolio,
"requestor_email": "requestor@example.com",
"permissions": permissions,
+ "date": date.today(),
},
)
self.assertTrue(result)
diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py
index f39f11517..2b7f89ac9 100644
--- a/src/registrar/tests/test_emails.py
+++ b/src/registrar/tests/test_emails.py
@@ -108,6 +108,82 @@ class TestEmails(TestCase):
self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"])
+ @boto3_mocking.patching
+ @override_settings(IS_PRODUCTION=True, BASE_URL="manage.get.gov")
+ def test_email_production_subject_and_url_check(self):
+ """Test sending an email in production that:
+ 1. Does not have a prefix in the email subject (no [MANAGE])
+ 2. Uses the production URL in the email body of manage.get.gov still"""
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
+ send_templated_email(
+ "emails/update_to_approved_domain.txt",
+ "emails/update_to_approved_domain_subject.txt",
+ "doesnotexist@igorville.com",
+ context={"domain": "test", "user": "test", "date": 1, "changes": "test"},
+ bcc_address=None,
+ cc_addresses=["testy2@town.com", "mayor@igorville.gov"],
+ )
+
+ # check that an email was sent
+ self.assertTrue(self.mock_client.send_email.called)
+
+ # check the call sequence for the email
+ args, kwargs = self.mock_client.send_email.call_args
+ self.assertIn("Destination", kwargs)
+ self.assertIn("CcAddresses", kwargs["Destination"])
+
+ self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"])
+
+ # Grab email subject
+ email_subject = kwargs["Content"]["Simple"]["Subject"]["Data"]
+
+ # Check that the subject does NOT contain a prefix for production
+ self.assertNotIn("[MANAGE]", email_subject)
+ self.assertIn("An update was made to", email_subject)
+
+ # Grab email body
+ email_body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
+
+ # Check that manage_url is correctly set for production
+ self.assertIn("https://manage.get.gov", email_body)
+
+ @boto3_mocking.patching
+ @override_settings(IS_PRODUCTION=False, BASE_URL="https://getgov-rh.app.cloud.gov")
+ def test_email_non_production_subject_and_url_check(self):
+ """Test sending an email in production that:
+ 1. Does prefix in the email subject (ie [GETGOV-RH])
+ 2. Uses the sandbox url in the email body (ie getgov-rh.app.cloud.gov)"""
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
+ send_templated_email(
+ "emails/update_to_approved_domain.txt",
+ "emails/update_to_approved_domain_subject.txt",
+ "doesnotexist@igorville.com",
+ context={"domain": "test", "user": "test", "date": 1, "changes": "test"},
+ bcc_address=None,
+ cc_addresses=["testy2@town.com", "mayor@igorville.gov"],
+ )
+
+ # check that an email was sent
+ self.assertTrue(self.mock_client.send_email.called)
+
+ # check the call sequence for the email
+ args, kwargs = self.mock_client.send_email.call_args
+ self.assertIn("Destination", kwargs)
+ self.assertIn("CcAddresses", kwargs["Destination"])
+ self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"])
+
+ # Grab email subject
+ email_subject = kwargs["Content"]["Simple"]["Subject"]["Data"]
+
+ # Check that the subject DOES contain a prefix of the current sandbox
+ self.assertIn("[GETGOV-RH]", email_subject)
+
+ # Grab email body
+ email_body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
+
+ # Check that manage_url is correctly set of the sandbox
+ self.assertIn("https://getgov-rh.app.cloud.gov", email_body)
+
@boto3_mocking.patching
@less_console_noise_decorator
def test_submission_confirmation(self):
diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py
index fd53c21f8..697f045a3 100644
--- a/src/registrar/tests/test_management_scripts.py
+++ b/src/registrar/tests/test_management_scripts.py
@@ -7,6 +7,7 @@ from registrar.models.domain_group import DomainGroup
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_portfolio_permission import UserPortfolioPermission
+from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.constants import BranchChoices
from django.utils import timezone
from django.utils.module_loading import import_string
@@ -1465,6 +1466,7 @@ class TestCreateFederalPortfolio(TestCase):
self.executive_so_2 = SeniorOfficial.objects.create(
first_name="first", last_name="last", email="mango@igorville.gov", federal_agency=self.executive_agency_2
)
+
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
self.domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
@@ -1474,6 +1476,7 @@ class TestCreateFederalPortfolio(TestCase):
)
self.domain_request.approve()
self.domain_info = DomainInformation.objects.filter(domain_request=self.domain_request).get()
+ self.domain = Domain.objects.get(name="city.gov")
self.domain_request_2 = completed_domain_request(
name="icecreamforigorville.gov",
@@ -1517,7 +1520,6 @@ class TestCreateFederalPortfolio(TestCase):
FederalAgency.objects.all().delete()
User.objects.all().delete()
- @less_console_noise_decorator
def run_create_federal_portfolio(self, **kwargs):
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit",
@@ -1812,12 +1814,12 @@ class TestCreateFederalPortfolio(TestCase):
# We expect a error to be thrown when we dont pass parse requests or domains
with self.assertRaisesRegex(
- CommandError, "You must specify at least one of --parse_requests or --parse_domains."
+ CommandError, "You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
):
self.run_create_federal_portfolio(branch="executive")
with self.assertRaisesRegex(
- CommandError, "You must specify at least one of --parse_requests or --parse_domains."
+ CommandError, "You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
):
self.run_create_federal_portfolio(agency_name="test")
@@ -1854,6 +1856,143 @@ class TestCreateFederalPortfolio(TestCase):
self.assertEqual(existing_portfolio.notes, "Old notes")
self.assertEqual(existing_portfolio.creator, self.user)
+ @less_console_noise_decorator
+ def test_add_managers_from_domains(self):
+ """Test that all domain managers are added as portfolio managers."""
+
+ # Create users and assign them as domain managers
+ manager1 = User.objects.create(username="manager1", email="manager1@example.com")
+ manager2 = User.objects.create(username="manager2", email="manager2@example.com")
+ UserDomainRole.objects.create(user=manager1, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
+ UserDomainRole.objects.create(user=manager2, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
+
+ # Run the management command
+ self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_domains=True, add_managers=True)
+
+ # Check that the portfolio was created
+ self.portfolio = Portfolio.objects.get(federal_agency=self.federal_agency)
+
+ # Check that the users have been added as portfolio managers
+ permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user__in=[manager1, manager2])
+
+ # Check that the users have been added as portfolio managers
+ self.assertEqual(permissions.count(), 2)
+ for perm in permissions:
+ self.assertIn(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, perm.roles)
+
+ @less_console_noise_decorator
+ def test_add_invited_managers(self):
+ """Test that invited domain managers receive portfolio invitations."""
+
+ # create a domain invitation for the manager
+ _ = DomainInvitation.objects.create(
+ domain=self.domain, email="manager1@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
+ )
+
+ # Run the management command
+ self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_domains=True, add_managers=True)
+
+ # Check that the portfolio was created
+ self.portfolio = Portfolio.objects.get(federal_agency=self.federal_agency)
+
+ # Check that a PortfolioInvitation has been created for the invited email
+ invitation = PortfolioInvitation.objects.get(email="manager1@example.com", portfolio=self.portfolio)
+
+ # Verify the status of the invitation remains INVITED
+ self.assertEqual(
+ invitation.status,
+ PortfolioInvitation.PortfolioInvitationStatus.INVITED,
+ "PortfolioInvitation status should remain INVITED for non-existent users.",
+ )
+
+ # Verify that no duplicate invitations are created
+ self.run_create_federal_portfolio(
+ agency_name=self.federal_agency.agency, parse_requests=True, add_managers=True
+ )
+ invitations = PortfolioInvitation.objects.filter(email="manager1@example.com", portfolio=self.portfolio)
+ self.assertEqual(
+ invitations.count(),
+ 1,
+ "Duplicate PortfolioInvitation should not be created for the same email and portfolio.",
+ )
+
+ @less_console_noise_decorator
+ def test_no_duplicate_managers_added(self):
+ """Test that duplicate managers are not added multiple times."""
+ # Create a manager
+ manager = User.objects.create(username="manager", email="manager@example.com")
+ UserDomainRole.objects.create(user=manager, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
+
+ # Create a pre-existing portfolio
+ self.portfolio = Portfolio.objects.create(
+ organization_name=self.federal_agency.agency, federal_agency=self.federal_agency, creator=self.user
+ )
+
+ # Manually add the manager to the portfolio
+ UserPortfolioPermission.objects.create(
+ portfolio=self.portfolio, user=manager, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
+ )
+
+ # Run the management command
+ self.run_create_federal_portfolio(
+ agency_name=self.federal_agency.agency, parse_requests=True, add_managers=True
+ )
+
+ # Ensure that the manager is not duplicated
+ permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user=manager)
+ self.assertEqual(permissions.count(), 1)
+
+ @less_console_noise_decorator
+ def test_add_managers_skip_existing_portfolios(self):
+ """Test that managers are skipped when the portfolio already exists."""
+
+ # Create a pre-existing portfolio
+ self.portfolio = Portfolio.objects.create(
+ organization_name=self.federal_agency.agency, federal_agency=self.federal_agency, creator=self.user
+ )
+
+ domain_request_1 = completed_domain_request(
+ name="domain1.gov",
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ federal_agency=self.federal_agency,
+ user=self.user,
+ portfolio=self.portfolio,
+ )
+ domain_request_1.approve()
+ domain1 = Domain.objects.get(name="domain1.gov")
+
+ domain_request_2 = completed_domain_request(
+ name="domain2.gov",
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ federal_agency=self.federal_agency,
+ user=self.user,
+ portfolio=self.portfolio,
+ )
+ domain_request_2.approve()
+ domain2 = Domain.objects.get(name="domain2.gov")
+
+ # Create users and assign them as domain managers
+ manager1 = User.objects.create(username="manager1", email="manager1@example.com")
+ manager2 = User.objects.create(username="manager2", email="manager2@example.com")
+ UserDomainRole.objects.create(user=manager1, domain=domain1, role=UserDomainRole.Roles.MANAGER)
+ UserDomainRole.objects.create(user=manager2, domain=domain2, role=UserDomainRole.Roles.MANAGER)
+
+ # Run the management command
+ self.run_create_federal_portfolio(
+ agency_name=self.federal_agency.agency,
+ parse_requests=True,
+ add_managers=True,
+ skip_existing_portfolios=True,
+ )
+
+ # Check that managers were added to the portfolio
+ permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user__in=[manager1, manager2])
+ self.assertEqual(permissions.count(), 2)
+ for perm in permissions:
+ self.assertIn(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, perm.roles)
+
def test_skip_existing_portfolios(self):
"""Tests the skip_existing_portfolios to ensure that it doesn't add
suborgs, domain requests, and domain info."""
diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py
index 4401b73e8..cd2fe868d 100644
--- a/src/registrar/tests/test_models.py
+++ b/src/registrar/tests/test_models.py
@@ -1191,67 +1191,6 @@ class TestUser(TestCase):
User.objects.all().delete()
UserDomainRole.objects.all().delete()
- @patch.object(User, "has_edit_portfolio_permission", return_value=True)
- def test_portfolio_role_summary_admin(self, mock_edit_org):
- # Test if the user is recognized as an Admin
- self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
-
- @patch.multiple(
- User,
- has_view_all_domains_portfolio_permission=lambda self, portfolio: True,
- has_any_requests_portfolio_permission=lambda self, portfolio: True,
- has_edit_request_portfolio_permission=lambda self, portfolio: True,
- )
- def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self):
- # Test if the user has both 'View-only admin' and 'Domain requestor' roles
- self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin", "Domain requestor"])
-
- @patch.multiple(
- User,
- has_view_all_domains_portfolio_permission=lambda self, portfolio: True,
- has_any_requests_portfolio_permission=lambda self, portfolio: True,
- )
- def test_portfolio_role_summary_view_only_admin(self):
- # Test if the user is recognized as a View-only admin
- self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin"])
-
- @patch.multiple(
- User,
- has_view_portfolio_permission=lambda self, portfolio: True,
- has_edit_request_portfolio_permission=lambda self, portfolio: True,
- has_any_domains_portfolio_permission=lambda self, portfolio: True,
- )
- def test_portfolio_role_summary_member_domain_requestor_domain_manager(self):
- # Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles
- self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"])
-
- @patch.multiple(
- User,
- has_view_portfolio_permission=lambda self, portfolio: True,
- has_edit_request_portfolio_permission=lambda self, portfolio: True,
- )
- def test_portfolio_role_summary_member_domain_requestor(self):
- # Test if the user has 'Member' and 'Domain requestor' roles
- self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor"])
-
- @patch.multiple(
- User,
- has_view_portfolio_permission=lambda self, portfolio: True,
- has_any_domains_portfolio_permission=lambda self, portfolio: True,
- )
- def test_portfolio_role_summary_member_domain_manager(self):
- # Test if the user has 'Member' and 'Domain manager' roles
- self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
-
- @patch.multiple(User, has_view_portfolio_permission=lambda self, portfolio: True)
- def test_portfolio_role_summary_member(self):
- # Test if the user is recognized as a Member
- self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
-
- def test_portfolio_role_summary_empty(self):
- # Test if the user has no roles
- self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
-
@patch("registrar.models.User._has_portfolio_permission")
def test_has_view_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
diff --git a/src/registrar/tests/test_models_requests.py b/src/registrar/tests/test_models_requests.py
index c3528311d..b19b245e5 100644
--- a/src/registrar/tests/test_models_requests.py
+++ b/src/registrar/tests/test_models_requests.py
@@ -1106,7 +1106,7 @@ class TestDomainRequest(TestCase):
federal_agency=fed_agency,
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
)
- user_portfolio_permission = UserPortfolioPermission.objects.create( # noqa: F841
+ UserPortfolioPermission.objects.create(
user=self.dummy_user_3, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Adds cc'ed email in this test's allow list
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 27dff5e3a..1f7e9f262 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -477,7 +477,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.domain_with_ip.expiration_date = self.expiration_date_one_year_out()
self.domain_with_ip.save()
- @override_flag("domain_renewal", active=True)
def test_expiring_domain_on_detail_page_as_domain_manager(self):
"""If a user is a domain manager and their domain is expiring soon,
user should be able to see the "Renew to maintain access" link domain overview detail box."""
@@ -496,7 +495,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertNotContains(detail_page, "DNS needed")
self.assertNotContains(detail_page, "Expired")
- @override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True)
def test_expiring_domain_on_detail_page_in_org_model_as_a_non_domain_manager(self):
"""In org model: If a user is NOT a domain manager and their domain is expiring soon,
@@ -534,7 +532,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
)
self.assertContains(detail_page, "Contact one of the listed domain managers to renew the domain.")
- @override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True)
def test_expiring_domain_on_detail_page_in_org_model_as_a_domain_manager(self):
"""Inorg model: If a user is a domain manager and their domain is expiring soon,
@@ -555,7 +552,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
)
self.assertContains(detail_page, "Renew to maintain access")
- @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_and_sidebar_expiring(self):
"""If a user is a domain manager and their domain is expiring soon,
user should be able to see Renewal Form on the sidebar."""
@@ -584,7 +580,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"Renew {self.domain_to_renew.name}")
- @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_and_sidebar_expired(self):
"""If a user is a domain manager and their domain is expired,
user should be able to see Renewal Form on the sidebar."""
@@ -614,7 +609,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"Renew {self.domain_to_renew.name}")
- @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_your_contact_info_edit(self):
"""Checking that if a user is a domain manager they can edit the
Your Profile portion of the Renewal Form."""
@@ -634,7 +628,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertEqual(edit_page.status_code, 200)
self.assertContains(edit_page, "Review the details below and update any required information")
- @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_security_email_edit(self):
"""Checking that if a user is a domain manager they can edit the
Security Email portion of the Renewal Form."""
@@ -657,7 +650,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertEqual(edit_page.status_code, 200)
self.assertContains(edit_page, "A security contact should be capable of evaluating")
- @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_domain_manager_edit(self):
"""Checking that if a user is a domain manager they can edit the
Domain Manager portion of the Renewal Form."""
@@ -677,7 +669,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertEqual(edit_page.status_code, 200)
self.assertContains(edit_page, "Domain managers can update all information related to a domain")
- @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_not_expired_or_expiring(self):
"""Checking that if the user's domain is not expired or expiring that user should not be able
to access /renewal and that it should receive a 403."""
@@ -686,7 +677,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
renewal_page = self.client.get(reverse("domain-renewal", kwargs={"pk": self.domain_not_expiring.id}))
self.assertEqual(renewal_page.status_code, 403)
- @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_does_not_appear_if_not_domain_manager(self):
"""If user is not a domain manager and tries to access /renewal, user should receive a 403."""
with patch.object(Domain, "is_expired", self.custom_is_expired_true), patch.object(
@@ -695,7 +685,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
renewal_page = self.client.get(reverse("domain-renewal", kwargs={"pk": self.domain_no_domain_manager.id}))
self.assertEqual(renewal_page.status_code, 403)
- @override_flag("domain_renewal", active=True)
def test_ack_checkbox_not_checked(self):
"""If user don't check the checkbox, user should receive an error message."""
# Grab the renewal URL
@@ -707,7 +696,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
error_message = "Check the box if you read and agree to the requirements for operating a .gov domain."
self.assertContains(response, error_message)
- @override_flag("domain_renewal", active=True)
def test_ack_checkbox_checked(self):
"""If user check the checkbox and submits the form,
user should be redirected Domain Over page with an updated by 1 year expiration date"""
@@ -1063,6 +1051,23 @@ class TestDomainManagers(TestDomainOverview):
success_page = success_result.follow()
self.assertContains(success_page, "Failed to send email.")
+ @boto3_mocking.patching
+ @less_console_noise_decorator
+ @patch("registrar.views.domain.send_templated_email")
+ def test_domain_remove_manager(self, mock_send_templated_email):
+ """Removing a domain manager sends notification email to other domain managers."""
+ self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
+ self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
+ self.client.post(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id}))
+
+ # Verify that the notification emails were sent to domain manager
+ mock_send_templated_email.assert_called_once_with(
+ "emails/domain_manager_deleted_notification.txt",
+ "emails/domain_manager_deleted_notification_subject.txt",
+ to_address="info@example.com",
+ context=ANY,
+ )
+
@less_console_noise_decorator
@patch("registrar.views.domain.send_domain_invitation_email")
def test_domain_invitation_created(self, mock_send_domain_email):
@@ -2975,26 +2980,15 @@ class TestDomainRenewal(TestWithUser):
pass
super().tearDown()
- # Remove test_without_domain_renewal_flag when domain renewal is released as a feature
@less_console_noise_decorator
- @override_flag("domain_renewal", active=False)
- def test_without_domain_renewal_flag(self):
- self.client.force_login(self.user)
- domains_page = self.client.get("/")
- self.assertNotContains(domains_page, "will expire soon")
- self.assertNotContains(domains_page, "Expiring soon")
-
- @less_console_noise_decorator
- @override_flag("domain_renewal", active=True)
- def test_domain_renewal_flag_single_domain(self):
+ def test_domain_with_single_domain(self):
self.client.force_login(self.user)
domains_page = self.client.get("/")
self.assertContains(domains_page, "One domain will expire soon")
self.assertContains(domains_page, "Expiring soon")
@less_console_noise_decorator
- @override_flag("domain_renewal", active=True)
- def test_with_domain_renewal_flag_mulitple_domains(self):
+ def test_with_mulitple_domains(self):
today = datetime.now()
expiring_date = (today + timedelta(days=30)).strftime("%Y-%m-%d")
self.domain_with_another_expiring, _ = Domain.objects.get_or_create(
@@ -3010,8 +3004,7 @@ class TestDomainRenewal(TestWithUser):
self.assertContains(domains_page, "Expiring soon")
@less_console_noise_decorator
- @override_flag("domain_renewal", active=True)
- def test_with_domain_renewal_flag_no_expiring_domains(self):
+ def test_with_no_expiring_domains(self):
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expired_date).delete()
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete()
self.client.force_login(self.user)
@@ -3019,18 +3012,16 @@ class TestDomainRenewal(TestWithUser):
self.assertNotContains(domains_page, "will expire soon")
@less_console_noise_decorator
- @override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True)
- def test_domain_renewal_flag_single_domain_w_org_feature_flag(self):
+ def test_single_domain_w_org_feature_flag(self):
self.client.force_login(self.user)
domains_page = self.client.get("/")
self.assertContains(domains_page, "One domain will expire soon")
self.assertContains(domains_page, "Expiring soon")
@less_console_noise_decorator
- @override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True)
- def test_with_domain_renewal_flag_mulitple_domains_w_org_feature_flag(self):
+ def test_with_mulitple_domains_w_org_feature_flag(self):
today = datetime.now()
expiring_date = (today + timedelta(days=31)).strftime("%Y-%m-%d")
self.domain_with_another_expiring_org_model, _ = Domain.objects.get_or_create(
@@ -3046,9 +3037,8 @@ class TestDomainRenewal(TestWithUser):
self.assertContains(domains_page, "Expiring soon")
@less_console_noise_decorator
- @override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True)
- def test_with_domain_renewal_flag_no_expiring_domains_w_org_feature_flag(self):
+ def test_no_expiring_domains_w_org_feature_flag(self):
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expired_date).delete()
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete()
self.client.force_login(self.user)
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 1a6cf3f4e..6194668c2 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -1063,7 +1063,7 @@ class TestPortfolio(WebTest):
self.assertContains(response, "Invited")
self.assertContains(response, portfolio_invitation.email)
self.assertContains(response, "Admin")
- self.assertContains(response, "Viewer, all")
+ self.assertContains(response, "Viewer")
self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
self.assertContains(
diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py
index 40601cdc7..94e87a96b 100644
--- a/src/registrar/utility/email.py
+++ b/src/registrar/utility/email.py
@@ -3,6 +3,7 @@
import boto3
import logging
import textwrap
+import re
from datetime import datetime
from django.apps import apps
from django.conf import settings
@@ -48,6 +49,21 @@ def send_templated_email( # noqa
No valid recipient addresses are provided
"""
+ if context is None:
+ context = {}
+
+ env_base_url = settings.BASE_URL
+ # The regular expression is to get both http (localhost) and https (everything else)
+ env_name = re.sub(r"^https?://", "", env_base_url).split(".")[0]
+ # If NOT in prod, add env to the subject line
+ # IE adds [GETGOV-RH] if we are in the -RH sandbox
+ prefix = f"[{env_name.upper()}] " if not settings.IS_PRODUCTION else ""
+ # If NOT in prod, update instances of "manage.get.gov" links to point to
+ # current environment, ie "getgov-rh.app.cloud.gov"
+ manage_url = env_base_url if not settings.IS_PRODUCTION else "https://manage.get.gov"
+
+ context["manage_url"] = manage_url
+
# by default assume we can send to all addresses (prod has no whitelist)
sendable_cc_addresses = cc_addresses
@@ -70,8 +86,10 @@ def send_templated_email( # noqa
if email_body:
email_body.strip().lstrip("\n")
+ # Update the subject to have prefix here versus every email
subject_template = get_template(subject_template_name)
subject = subject_template.render(context=context)
+ subject = f"{prefix}{subject}"
try:
ses_client = boto3.client(
diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py
index 2ddf74cc0..1394f7d48 100644
--- a/src/registrar/utility/email_invitations.py
+++ b/src/registrar/utility/email_invitations.py
@@ -255,6 +255,7 @@ def send_portfolio_member_permission_update_email(requestor, permissions: UserPo
"portfolio": permissions.portfolio,
"requestor_email": requestor_email,
"permissions": permissions,
+ "date": date.today(),
},
)
except EmailSendingError:
diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py
index 24673ac4f..72826e570 100644
--- a/src/registrar/views/domain.py
+++ b/src/registrar/views/domain.py
@@ -366,7 +366,7 @@ class DomainRenewalView(DomainBaseView):
return HttpResponseRedirect(reverse("domain", kwargs={"pk": pk}))
# if not valid, render the template with error messages
- # passing editable, has_domain_renewal_flag, and is_editable for re-render
+ # passing editable and is_editable for re-render
return render(
request,
"domain_renewal.html",
@@ -374,7 +374,6 @@ class DomainRenewalView(DomainBaseView):
"domain": domain,
"form": form,
"is_editable": True,
- "has_domain_renewal_flag": True,
"is_domain_manager": True,
},
)
@@ -1348,10 +1347,49 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
# Delete the object
super().form_valid(form)
+ # Email all domain managers that domain manager has been removed
+ domain = self.object.domain
+
+ context = {
+ "domain": domain,
+ "removed_by": self.request.user,
+ "manager_removed": self.object.user,
+ "date": date.today(),
+ "changes": "Domain Manager",
+ }
+ self.email_domain_managers(
+ domain,
+ "emails/domain_manager_deleted_notification.txt",
+ "emails/domain_manager_deleted_notification_subject.txt",
+ context,
+ )
+
# Add a success message
messages.success(self.request, self.get_success_message())
return redirect(self.get_success_url())
+ def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
+ manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list(
+ "user", flat=True
+ )
+ emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True))
+
+ for email in emails:
+ try:
+ send_templated_email(
+ template,
+ subject_template,
+ to_address=email,
+ context=context,
+ )
+ except EmailSendingError:
+ logger.warning(
+ "Could not send notification email to %s for domain %s",
+ email,
+ domain.name,
+ exc_info=True,
+ )
+
def post(self, request, *args, **kwargs):
"""Custom post implementation to ensure last userdomainrole is not removed and to
redirect to home in the event that the user deletes themselves"""