Merge branch 'main' of https://github.com/cisagov/manage.get.gov into hotgov/2852-property-to-portfolio-related-fields

This commit is contained in:
asaki222 2024-10-21 19:58:39 -04:00
commit 5e8612bfd1
No known key found for this signature in database
GPG key ID: 2C4F802060E06EA4
28 changed files with 1868 additions and 1229 deletions

View file

@ -1,11 +1,7 @@
## Ticket
<!-- PR title format: `#issue_number: Descriptive name ideally matching ticket name - [sandbox]`-->
Resolves #00
<!--Reminder, when a code change is made that is user facing, beyond content updates, then the following are required:
- a developer approves the PR
- a designer approves the PR or checks off all relevant items in this checklist.
All other changes require just a single approving review.-->
## Changes
@ -45,82 +41,63 @@ All other changes require just a single approving review.-->
- [ ] Met the acceptance criteria, or will meet them in a subsequent PR
- [ ] Created/modified automated tests
- [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve)
- [ ] Messaged on Slack or in standup to notify the team that a PR is ready for review
- [ ] Changes to “how we do things” are documented in READMEs and or onboarding guide
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited.
- [ ] Update documentation in READMEs and/or onboarding guide
#### Ensured code standards are met (Original Developer)
- [ ] All new functions and methods are commented using plain language
- [ ] Did dependency updates in Pipfile also get changed in requirements.txt?
<!-- Mark "- N/A" and check at the end of each check that is not applicable to your PR -->
- [ ] If any updated dependencies on Pipfile, also update dependencies in requirements.txt.
- [ ] Interactions with external systems are wrapped in try/except
- [ ] Error handling exists for unusual or missing values
#### Validated user-facing changes (if applicable)
- [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing
- [ ] Tag @dotgov-designers in this PR's Reviewers for design review. If code is not user-facing, delete design reviewer checklist
- [ ] Verify new pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing
- [ ] Checked keyboard navigability
- [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI)
- [ ] Add at least 1 designer as PR reviewer
### As a code reviewer, I have
#### Reviewed, tested, and left feedback about the changes
- [ ] Pulled this branch locally and tested it
- [ ] Reviewed this code and left comments
- [ ] Verified code meets all checks above. Address any checks that are not satisfied
- [ ] Reviewed this code and left comments. Indicate if comments must be addressed before code is merged
- [ ] Checked that all code is adequately covered by tests
- [ ] Made it clear which comments need to be addressed before this work is merged
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited.
#### Ensured code standards are met (Code reviewer)
- [ ] All new functions and methods are commented using plain language
- [ ] Interactions with external systems are wrapped in try/except
- [ ] Error handling exists for unusual or missing values
- [ ] (Rarely needed) Did dependency updates in Pipfile also get changed in requirements.txt?
- [ ] Verify migrations are valid and do not conflict with existing migrations
#### Validated user-facing changes as a developer
**Note:** Multiple code reviewers can share the checklists above, a second reviewer should not make a duplicate checklist. All checks should be checked before approving, even those labeled N/A.
- [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing
- [ ] Checked keyboard navigability
- [ ] Meets all designs and user flows provided by design/product
- [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI)
- [ ] Tested with multiple browsers, the suggestion is to use ones that the developer didn't (check off which ones were used)
- [ ] Chrome
- [ ] Microsoft Edge
- [ ] FireFox
- [ ] Safari
- [ ] (Rarely needed) Tested as both an analyst and applicant user
**Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist
### As a designer reviewer, I have
#### Verified that the changes match the design intention
- [ ] Checked that the design translated visually
- [ ] Checked behavior
- [ ] Checked behavior. Comment any found issues or broken flows.
- [ ] Checked different states (empty, one, some, error)
- [ ] Checked for landmarks, page heading structure, and links
- [ ] Tried to break the intended flow
#### Validated user-facing changes as a designer
- [ ] Checked keyboard navigability
- [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI)
- [ ] Tested with multiple browsers (check off which ones were used)
- [ ] Chrome
- [ ] Microsoft Edge
- [ ] FireFox
- [ ] Safari
- [ ] (Rarely needed) Tested as both an analyst and applicant user
### References
- [Code review best practices](../docs/dev-practices/code_review.md)
## Screenshots
<!-- If this PR makes visible interface changes, an image of the finished interface can help reviewers

View file

@ -14,17 +14,6 @@ There are a handful of things we do not commit to the repository:
For developers, you can auto-deploy your code to your sandbox (if applicable) by naming your branch thusly: jsd/123-feature-description
Where 'jsd' stands for your initials and sandbox environment name (if you were called John Smith Doe), and 123 matches the ticket number if applicable.
## Approvals
When a code change is made that is not user facing, then the following is required:
- a developer approves the PR
When a code change is made that is user facing, beyond content updates, then the following are required:
- a developer approves the PR
- a designer approves the PR or checks off all relevant items in this checklist
Content or document updates require a single person to approve.
## Project Management
We use [Github Projects](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects) for project management and tracking.
@ -39,14 +28,6 @@ Every issue in this respository and on the project board should be appropriately
We also have labels for each discipline and for research and project management related tasks. While this repository and project board track development work, we try to document all work related to the project here as well.
## Pull request etiquette
- The submitter is in charge of merging their PRs unless the approver is given explicit permission.
- Do not commit to another person's branch unless given explicit permission.
- Keep pull requests as small as possible. This makes them easier to review and track changes.
- Bias towards approving i.e. "good to merge once X is fixed" rather than blocking until X is fixed, requiring an additional review.
- Write descriptive pull requests. This is not only something that makes it easier to review, but is a great source of documentation.
## Branch Naming
Our branch naming convention is `name/topic-or-feature`, for example: `lmm/add-contributing-doc`.
Our branch naming convention is `name/issue_no-description`, for example: `lmm/1234-add-contributing-doc`.

View file

@ -0,0 +1,31 @@
## Code Review
Pull requests should be titled in the format of `#issue_number: Descriptive name ideally matching ticket name - [sandbox]`
Pull requests including a migration should be suffixed with ` - MIGRATION`
After creating a pull request, pull request submitters should:
- Add at least 2 developers as PR reviewers (only 1 will need to approve).
- Message on Slack or in standup to notify the team that a PR is ready for review.
- If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file.
## Pull request approvals
Code changes on user-facing features (excluding content updates) require approval from at least one developer and one designer.
All other changes require a single approving review.
The submitter is responsible for merging their PR unless the approver is given explicit permission. Similarly, do not commit to another person's branch unless given explicit permission.
Bias towards approving i.e. "good to merge once X is fixed" rather than blocking until X is fixed, requiring an additional review.
## Pull Requests for User-facing changes
When making or reviewing user-facing changes, test that your changes work on multiple browsers including Chrome, Microsoft Edge, Firefox, and Safari.
Add new pages to the .pa11yci file so they are included in our automated accessibility testing.
## Other Pull request norms
- Keep pull requests as small as possible. This makes them easier to review and track changes.
- Write descriptive pull requests. This is not only something that makes it easier to review, but is a great source of documentation.
## Coding standards
### Plain language
All functions and methods should use plain language.

2009
src/Pipfile.lock generated

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,7 @@ services:
# Run Django in debug mode on local
- DJANGO_DEBUG=True
# Set DJANGO_LOG_LEVEL in env
- DJANGO_LOG_LEVEL
- DJANGO_LOG_LEVEL=DEBUG
# Run Django without production flags
- IS_PRODUCTION=False
# Tell Django where it is being hosted

View file

@ -1612,8 +1612,9 @@ class DomainRequestsTable extends LoadTableBase {
const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
// The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page)
// Even if the request is not deletable, we may need these empty strings for the td if the deletable column is displayed
let modalTrigger = '';
// If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user
let modalTrigger = `
<span class="usa-sr-only">Domain request cannot be deleted now. Edit the request for more information.</span>`;
let markupCreatorRow = '';
@ -1625,8 +1626,8 @@ class DomainRequestsTable extends LoadTableBase {
`
}
// If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
if (request.is_deletable) {
// If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
let modalHeading = '';
let modalDescription = '';

View file

@ -898,3 +898,10 @@ ul.add-list-reset {
font-weight: 600;
font-size: .8125rem;
}
.change-form .usa-table {
td {
color: inherit !important;
background-color: transparent !important;
}
}

View file

@ -476,8 +476,10 @@ class JsonServerFormatter(ServerFormatter):
def format(self, record):
formatted_record = super().format(record)
if not hasattr(record, "server_time"):
record.server_time = self.formatTime(record, self.datefmt)
log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record}
return json.dumps(log_entry)

View file

@ -1,4 +1,5 @@
import logging
import random
from faker import Faker
from django.db import transaction
@ -51,23 +52,24 @@ class UserPortfolioPermissionFixture:
user_portfolio_permissions_to_create = []
for user in users:
for portfolio in portfolios:
try:
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
user_portfolio_permission = UserPortfolioPermission(
user=user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
)
user_portfolio_permissions_to_create.append(user_portfolio_permission)
else:
logger.info(
f"Permission exists for user '{user.username}' "
f"on portfolio '{portfolio.organization_name}'."
)
except Exception as e:
logger.warning(e)
# Assign a random portfolio to a user
portfolio = random.choice(portfolios) # nosec
try:
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
user_portfolio_permission = UserPortfolioPermission(
user=user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
)
user_portfolio_permissions_to_create.append(user_portfolio_permission)
else:
logger.info(
f"Permission exists for user '{user.username}' "
f"on portfolio '{portfolio.organization_name}'."
)
except Exception as e:
logger.warning(e)
# Bulk create permissions
cls._bulk_create_permissions(user_portfolio_permissions_to_create)

View file

@ -137,6 +137,20 @@ class UserFixture:
"email": "annagingle@truss.works",
"title": "Sweetwater sailor",
},
{
"username": "cce058bc-9e52-456b-99ff-f5775c481c8f",
"first_name": "Elizabeth",
"last_name": "Liao",
"email": "elizabeth.liao@cisa.dhs.gov",
"title": "Software Engineer",
},
{
"username": "c9c64cd5-bc76-45ef-85cd-4f6eefa9e998",
"first_name": "Samiyah",
"last_name": "Key",
"email": "skey@truss.works",
"title": "Designer",
},
]
STAFF = [
@ -231,6 +245,18 @@ class UserFixture:
"last_name": "Gingle-Analyst",
"email": "annagingle+analyst@truss.works",
},
{
"username": "373f7073-f90b-49d8-8da2-459246fa33bd",
"first_name": "Elizabeth-Analyst",
"last_name": "Liao-Analyst",
"email": "elizabeth.liao@gwe.cisa.dhs.gov",
},
{
"username": "ee1e68da-41a5-47f7-949b-d8a4e9e2b9d2",
"first_name": "Samiyah-Analyst",
"last_name": "Key-Analyst",
"email": "skey+1@truss.works",
},
]
# Additional emails to add to the AllowedEmail whitelist.

View file

@ -34,6 +34,7 @@ class OrganizationTypeForm(RegistrarForm):
choices=DomainRequest.OrganizationChoicesVerbose.choices,
widget=forms.RadioSelect,
error_messages={"required": "Select the type of organization you represent."},
label="What kind of U.S.-based government organization do you represent?",
)
@ -77,6 +78,7 @@ class OrganizationFederalForm(RegistrarForm):
federal_type = forms.ChoiceField(
choices=BranchChoices.choices,
widget=forms.RadioSelect,
label="Which federal branch is your organization in?",
error_messages={"required": ("Select the part of the federal government your organization is in.")},
)
@ -88,7 +90,8 @@ class OrganizationElectionForm(RegistrarForm):
(True, "Yes"),
(False, "No"),
],
)
),
label="Is your organization an election office?",
)
def clean_is_election_board(self):
@ -450,6 +453,7 @@ class OtherContactsForm(RegistrarForm):
message="Response must be less than 320 characters.",
)
],
help_text="Enter an email address in the required format, like name@example.com.",
)
phone = PhoneNumberField(
label="Phone",

View file

@ -831,7 +831,6 @@ class DomainRequest(TimeStampedModel):
if custom_email_content:
context["custom_email_content"] = custom_email_content
send_templated_email(
email_template,
email_template_subject,
@ -877,7 +876,6 @@ class DomainRequest(TimeStampedModel):
DraftDomain = apps.get_model("registrar.DraftDomain")
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
raise ValueError("Requested domain is not a valid domain name.")
# if the domain has not been submitted before this must be the first time
if not self.first_submitted_date:
self.first_submitted_date = timezone.now().date()

View file

@ -108,16 +108,6 @@ class UserPortfolioPermission(TimeStampedModel):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()
# Check if a user is set without accessing the related object.
has_user = bool(self.user_id)
if self.pk is None and has_user:
existing_permissions = UserPortfolioPermission.objects.filter(user=self.user)
if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permissions.exists():
raise ValidationError(
"This user is already assigned to a portfolio. "
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
)
# Check if portfolio is set without accessing the related object.
has_portfolio = bool(self.portfolio_id)
if not has_portfolio and self._get_portfolio_permissions():
@ -125,3 +115,19 @@ class UserPortfolioPermission(TimeStampedModel):
if has_portfolio and not self._get_portfolio_permissions():
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
# Check if a user is set without accessing the related object.
has_user = bool(self.user_id)
if has_user:
existing_permission_pks = UserPortfolioPermission.objects.filter(user=self.user).values_list(
"pk", flat=True
)
if (
not flag_is_active_for_user(self.user, "multiple_portfolios")
and existing_permission_pks.exists()
and self.pk not in existing_permission_pks
):
raise ValidationError(
"This user is already assigned to a portfolio. "
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
)

View file

@ -2,23 +2,21 @@
{% load static url_helpers %}
{% block detail_content %}
<table>
<table class="usa-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<tr>
<th data-sortable scope="col" role="columnheader">Name</th>
<th data-sortable scope="col" role="columnheader">
Status
</th>
</tr>
</thead>
<tbody>
{% for domain_request in domain_requests %}
{% url 'admin:registrar_domainrequest_change' domain_request.pk as url %}
<tr>
<td><a href={{url}}>{{ domain_request }}</a></td>
{% if domain_request.get_status_display %}
<td>{{ domain_request.get_status_display }}</td>
{% else %}
<td>None</td>
{% endif %}
<td data-sort-value="{{ domain_request }}"> <a href={{url}}>{{ domain_request }}</a></td>
<td data-sort-value="{{ domain_request.get_status_display}}"> {{ domain_request.get_status_display|default:"None" }} </td>
</tr>
{% endfor %}
</tbody>

View file

@ -2,11 +2,11 @@
{% load static url_helpers %}
{% block detail_content %}
<table>
<table class="usa-table">
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th data-sortable scope="col" role="columnheader">Name</th>
<th data-sortable scope="col" role="columnheader">State</th>
</tr>
</thead>
<tbody>
@ -15,11 +15,11 @@
{% with domain=domain_info.domain %}
{% url 'admin:registrar_domain_change' domain.pk as url %}
<tr>
<td><a href={{url}}>{{ domain }}</a></td>
<td data-sort-value="{{ domain }}"> <a href={{url}}>{{ domain }}</a></td>
{% if domain and domain.get_state_display %}
<td>{{ domain.get_state_display }}</td>
<td data-sort-value="{{ domain.get_state_display }}"> {{ domain.get_state_display }} </td>
{% else %}
<td>None</td>
<td data-sort-value="None"> None</td>
{% endif %}
</tr>
{% endwith %}

View file

@ -4,11 +4,31 @@
{% block title %}Add a domain manager | {% endblock %}
{% block domain_content %}
{% block breadcrumb %}
{% url 'domain-users' pk=domain.id as url %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain manager breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Domain managers</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Add a domain manager</span>
</li>
</ol>
</nav>
{% endblock breadcrumb %}
<h1>Add a domain manager</h1>
<p>You can add another user to help manage your domain. They will need to sign
in to the .gov registrar with their Login.gov account.
{% if has_organization_feature_flag %}
<p>
You can add another user to help manage your domain. Users can only be a member of one .gov organization,
and they'll need to sign in with their Login.gov account.
</p>
{% else %}
<p>
You can add another user to help manage your domain. They will need to sign in to the .gov registrar with
their Login.gov account.
</p>
{% endif %}
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}

View file

@ -2,7 +2,7 @@
{% load field_helpers %}
{% block form_instructions %}
<h2 class="margin-bottom-05">
<h2 id="id_domain_request_federal_org_header" class="margin-bottom-05">
Which federal branch is your organization in?
</h2>
{% endblock %}

View file

@ -8,8 +8,7 @@
<p>
Domain managers can update all information related to a domain within the
.gov registrar, including contact details, senior official, security
email, and DNS name servers.
.gov registrar, including security email and DNS name servers.
</p>
<ul class="usa-list">
@ -17,7 +16,8 @@
<li>After adding a domain manager, an email invitation will be sent to that user with
instructions on how to set up an account.</li>
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
<li>Domains must have at least one domain manager. You cant remove yourself as a domain manager if youre the only one assigned to this domain. Add another domain manager before you remove yourself from this domain.</li>
<li>All domain managers will be notified when updates are made to this domain.</li>
<li>Domains must have at least one domain manager. You cant remove yourself as a domain manager if youre the only one assigned to this domain.</li>
</ul>
{% if domain.permissions %}

View file

@ -0,0 +1,31 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,
An update was made to a domain you manage.
DOMAIN: {{domain}}
UPDATED BY: {{user}}
UPDATED ON: {{date}}
INFORMATION UPDATED: {{changes}}
You can view this update in the .gov registrar <https://manage.get.gov/>.
Get help with managing your .gov domain <https://get.gov/help/domain-management/>.
----------------------------------------------------------------
WHY DID YOU RECEIVE THIS EMAIL?
Youre listed as a domain manager for {{domain}}, so youll receive a notification whenever changes are made to that domain.
If you have questions or concerns, reach out to the person who made the change 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 <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
An update was made to {{domain}}

View file

@ -61,11 +61,58 @@ class TestEmails(TestCase):
# Assert that an email wasn't sent
self.assertFalse(self.mock_client.send_email.called)
@boto3_mocking.patching
def test_email_with_cc(self):
"""Test sending email with cc works"""
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"])
@boto3_mocking.patching
@override_settings(IS_PRODUCTION=True)
def test_email_with_cc_in_prod(self):
"""Test sending email with cc works in prod"""
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"])
@boto3_mocking.patching
@less_console_noise_decorator
def test_submission_confirmation(self):
"""Submission confirmation email works."""
domain_request = completed_domain_request()
domain_request = completed_domain_request(user=User.objects.create(username="test", email="testy@town.com"))
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
@ -102,7 +149,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator
def test_submission_confirmation_no_current_website_spacing(self):
"""Test line spacing without current_website."""
domain_request = completed_domain_request(has_current_website=False)
domain_request = completed_domain_request(
has_current_website=False, user=User.objects.create(username="test", email="testy@town.com")
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
@ -115,7 +164,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator
def test_submission_confirmation_current_website_spacing(self):
"""Test line spacing with current_website."""
domain_request = completed_domain_request(has_current_website=True)
domain_request = completed_domain_request(
has_current_website=True, user=User.objects.create(username="test", email="testy@town.com")
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
@ -132,7 +183,11 @@ class TestEmails(TestCase):
# Create fake creator
_creator = User.objects.create(
username="MrMeoward", first_name="Meoward", last_name="Jones", phone="(888) 888 8888"
username="MrMeoward",
first_name="Meoward",
last_name="Jones",
phone="(888) 888 8888",
email="testy@town.com",
)
# Create a fake domain request
@ -149,7 +204,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator
def test_submission_confirmation_no_other_contacts_spacing(self):
"""Test line spacing without other contacts."""
domain_request = completed_domain_request(has_other_contacts=False)
domain_request = completed_domain_request(
has_other_contacts=False, user=User.objects.create(username="test", email="testy@town.com")
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
@ -161,7 +218,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator
def test_submission_confirmation_alternative_govdomain_spacing(self):
"""Test line spacing with alternative .gov domain."""
domain_request = completed_domain_request(has_alternative_gov_domain=True)
domain_request = completed_domain_request(
has_alternative_gov_domain=True, user=User.objects.create(username="test", email="testy@town.com")
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
@ -174,7 +233,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator
def test_submission_confirmation_no_alternative_govdomain_spacing(self):
"""Test line spacing without alternative .gov domain."""
domain_request = completed_domain_request(has_alternative_gov_domain=False)
domain_request = completed_domain_request(
has_alternative_gov_domain=False, user=User.objects.create(username="test", email="testy@town.com")
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
@ -187,7 +248,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator
def test_submission_confirmation_about_your_organization_spacing(self):
"""Test line spacing with about your organization."""
domain_request = completed_domain_request(has_about_your_organization=True)
domain_request = completed_domain_request(
has_about_your_organization=True, user=User.objects.create(username="test", email="testy@town.com")
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
@ -200,7 +263,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator
def test_submission_confirmation_no_about_your_organization_spacing(self):
"""Test line spacing without about your organization."""
domain_request = completed_domain_request(has_about_your_organization=False)
domain_request = completed_domain_request(
has_about_your_organization=False, user=User.objects.create(username="test", email="testy@town.com")
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
@ -213,7 +278,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator
def test_submission_confirmation_anything_else_spacing(self):
"""Test line spacing with anything else."""
domain_request = completed_domain_request(has_anything_else=True)
domain_request = completed_domain_request(
has_anything_else=True, user=User.objects.create(username="test", email="testy@town.com")
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
@ -225,7 +292,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator
def test_submission_confirmation_no_anything_else_spacing(self):
"""Test line spacing without anything else."""
domain_request = completed_domain_request(has_anything_else=False)
domain_request = completed_domain_request(
has_anything_else=False, user=User.objects.create(username="test", email="testy@town.com")
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args

View file

@ -299,6 +299,7 @@ class TestUserPortfolioPermission(TestCase):
@less_console_noise_decorator
def setUp(self):
self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov")
self.user2, _ = User.objects.get_or_create(email="user2@igorville.gov", username="user2")
super().setUp()
def tearDown(self):
@ -336,16 +337,15 @@ class TestUserPortfolioPermission(TestCase):
@override_flag("multiple_portfolios", active=False)
def test_clean_on_creates_multiple_portfolios(self):
"""Ensures that a user cannot create multiple portfolio permission objects when the flag is disabled"""
# Create an instance of User with a portfolio but no roles or additional permissions
# Create an instance of User with a single portfolio
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California")
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California")
portfolio_permission_2 = UserPortfolioPermission(
portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# This should work as intended
portfolio_permission.clean()
@ -353,7 +353,37 @@ class TestUserPortfolioPermission(TestCase):
with self.assertRaises(ValidationError) as cm:
portfolio_permission_2.clean()
portfolio_permission_2, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
self.assertEqual(
cm.exception.message,
(
"This user is already assigned to a portfolio. "
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
),
)
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=False)
def test_multiple_portfolio_reassignment(self):
"""Ensures that a user cannot be assigned to multiple portfolios based on reassignment"""
# Create an instance of two users with separate portfolios
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Motel California")
portfolio_permission_2 = UserPortfolioPermission(
portfolio=portfolio_2, user=self.user2, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# This should work as intended
portfolio_permission.clean()
portfolio_permission_2.clean()
# Reassign the portfolio of "user2" to "user" (this should throw an error
# preventing "user" from having multiple portfolios)
with self.assertRaises(ValidationError) as cm:
portfolio_permission_2.user = self.user
portfolio_permission_2.clean()
self.assertEqual(
cm.exception.message,

View file

@ -305,7 +305,7 @@ class TestDomainRequest(TestCase):
@less_console_noise_decorator
def test_submit_from_withdrawn_sends_email(self):
msg = "Create a withdrawn domain request and submit it and see if email was sent."
user, _ = User.objects.get_or_create(username="testy")
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=user)
self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hi", expected_email=user.email)
@ -324,14 +324,14 @@ class TestDomainRequest(TestCase):
@less_console_noise_decorator
def test_approve_sends_email(self):
msg = "Create a domain request and approve it and see if email was sent."
user, _ = User.objects.get_or_create(username="testy")
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email)
@less_console_noise_decorator
def test_withdraw_sends_email(self):
msg = "Create a domain request and withdraw it and see if email was sent."
user, _ = User.objects.get_or_create(username="testy")
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
self.check_email_sent(
domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email
@ -339,7 +339,7 @@ class TestDomainRequest(TestCase):
def test_reject_sends_email(self):
"Create a domain request and reject it and see if email was sent."
user, _ = User.objects.get_or_create(username="testy")
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user)
expected_email = user.email
email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)

View file

@ -65,6 +65,10 @@ class TestWithDomainPermissions(TestWithUser):
datetime.combine(date.today() + timedelta(days=1), datetime.min.time())
),
)
self.domain_dns_needed, _ = Domain.objects.get_or_create(
name="dns-needed.gov",
state=Domain.State.DNS_NEEDED,
)
self.domain_deleted, _ = Domain.objects.get_or_create(
name="deleted.gov",
state=Domain.State.DELETED,
@ -91,6 +95,7 @@ class TestWithDomainPermissions(TestWithUser):
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_just_nameserver)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_on_hold)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_deleted)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dns_needed)
self.role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
@ -99,6 +104,9 @@ class TestWithDomainPermissions(TestWithUser):
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_dsdata, role=UserDomainRole.Roles.MANAGER
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_dns_needed, role=UserDomainRole.Roles.MANAGER
)
UserDomainRole.objects.get_or_create(
user=self.user,
domain=self.domain_multdsdata,
@ -236,6 +244,7 @@ class TestDomainDetail(TestDomainOverview):
# At the time of this test's writing, there are 6 UNKNOWN domains inherited
# from constructors. Let's reset.
with less_console_noise():
PublicContact.objects.all().delete()
Domain.objects.all().delete()
UserDomainRole.objects.all().delete()
@ -1967,3 +1976,292 @@ class TestDomainDNSSEC(TestDomainOverview):
self.assertContains(
result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_SHA256)), count=2, status_code=200
)
class TestDomainChangeNotifications(TestDomainOverview):
"""Test email notifications on updates to domain information"""
@classmethod
def setUpClass(cls):
super().setUpClass()
allowed_emails = [
AllowedEmail(email="info@example.com"),
AllowedEmail(email="doesnotexist@igorville.com"),
]
AllowedEmail.objects.bulk_create(allowed_emails)
def setUp(self):
super().setUp()
self.mock_client_class = MagicMock()
self.mock_client = self.mock_client_class.return_value
@classmethod
def tearDownClass(cls):
super().tearDownClass()
AllowedEmail.objects.all().delete()
@boto3_mocking.patching
@less_console_noise_decorator
def test_notification_on_org_name_change(self):
"""Test that an email is sent when the organization name is changed."""
# We may end up sending emails on org name changes later, but it will be addressed
# in the portfolio itself, rather than the individual domain.
self.domain_information.organization_name = "Town of Igorville"
self.domain_information.address_line1 = "123 Main St"
self.domain_information.city = "Igorville"
self.domain_information.state_territory = "IL"
self.domain_information.zipcode = "62052"
self.domain_information.save()
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["organization_name"] = "Not igorville"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
org_name_page.form.submit()
# Check that an email was sent
self.assertTrue(self.mock_client.send_email.called)
# Check email content
# check the call sequence for the email
_, kwargs = self.mock_client.send_email.call_args
self.assertIn("Content", kwargs)
self.assertIn("Simple", kwargs["Content"])
self.assertIn("Subject", kwargs["Content"]["Simple"])
self.assertIn("Body", kwargs["Content"]["Simple"])
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("DOMAIN: igorville.gov", body)
self.assertIn("UPDATED BY: First Last info@example.com", body)
self.assertIn("INFORMATION UPDATED: Organization details", body)
@boto3_mocking.patching
@less_console_noise_decorator
def test_no_notification_on_org_name_change_with_portfolio(self):
"""Test that an email is not sent on org name change when the domain is in a portfolio"""
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
self.domain_information.organization_name = "Town of Igorville"
self.domain_information.address_line1 = "123 Main St"
self.domain_information.city = "Igorville"
self.domain_information.state_territory = "IL"
self.domain_information.zipcode = "62052"
self.domain_information.portfolio = portfolio
self.domain_information.save()
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["organization_name"] = "Not igorville"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
org_name_page.form.submit()
# Check that an email was not sent
self.assertFalse(self.mock_client.send_email.called)
@boto3_mocking.patching
@less_console_noise_decorator
def test_no_notification_on_change_by_analyst(self):
"""Test that an email is not sent on org name change when the domain is in a portfolio"""
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
self.domain_information.organization_name = "Town of Igorville"
self.domain_information.address_line1 = "123 Main St"
self.domain_information.city = "Igorville"
self.domain_information.state_territory = "IL"
self.domain_information.zipcode = "62052"
self.domain_information.portfolio = portfolio
self.domain_information.save()
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
session = self.app.session
session["analyst_action"] = "foo"
session["analyst_action_location"] = self.domain.id
session.save()
org_name_page.form["organization_name"] = "Not igorville"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
org_name_page.form.submit()
# Check that an email was not sent
self.assertFalse(self.mock_client.send_email.called)
@boto3_mocking.patching
@less_console_noise_decorator
def test_notification_on_security_email_change(self):
"""Test that an email is sent when the security email is changed."""
security_email_page = self.app.get(reverse("domain-security-email", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
security_email_page.form["security_email"] = "new_security@example.com"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
security_email_page.form.submit()
self.assertTrue(self.mock_client.send_email.called)
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("DOMAIN: igorville.gov", body)
self.assertIn("UPDATED BY: First Last info@example.com", body)
self.assertIn("INFORMATION UPDATED: Security email", body)
@boto3_mocking.patching
@less_console_noise_decorator
def test_notification_on_dnssec_enable(self):
"""Test that an email is sent when DNSSEC is enabled."""
page = self.client.get(reverse("domain-dns-dnssec", kwargs={"pk": self.domain_multdsdata.id}))
self.assertContains(page, "Disable DNSSEC")
# Prepare the data for the POST request
post_data = {
"disable_dnssec": "Disable DNSSEC",
}
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
updated_page = self.client.post(
reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id}),
post_data,
follow=True,
)
self.assertEqual(updated_page.status_code, 200)
self.assertContains(updated_page, "Enable DNSSEC")
self.assertTrue(self.mock_client.send_email.called)
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("DOMAIN: igorville.gov", body)
self.assertIn("UPDATED BY: First Last info@example.com", body)
self.assertIn("INFORMATION UPDATED: DNSSEC / DS Data", body)
@boto3_mocking.patching
@less_console_noise_decorator
def test_notification_on_ds_data_change(self):
"""Test that an email is sent when DS data is changed."""
ds_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
# Add DS data
ds_data_page.forms[0]["form-0-key_tag"] = "12345"
ds_data_page.forms[0]["form-0-algorithm"] = "13"
ds_data_page.forms[0]["form-0-digest_type"] = "2"
ds_data_page.forms[0]["form-0-digest"] = "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
ds_data_page.forms[0].submit()
# check that the email was sent
self.assertTrue(self.mock_client.send_email.called)
# check some stuff about the email
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("DOMAIN: igorville.gov", body)
self.assertIn("UPDATED BY: First Last info@example.com", body)
self.assertIn("INFORMATION UPDATED: DNSSEC / DS Data", body)
@boto3_mocking.patching
@less_console_noise_decorator
def test_notification_on_senior_official_change(self):
"""Test that an email is sent when the senior official information is changed."""
self.domain_information.senior_official = Contact.objects.create(
first_name="Old", last_name="Official", title="Manager", email="old_official@example.com"
)
self.domain_information.save()
senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
senior_official_page.form["first_name"] = "New"
senior_official_page.form["last_name"] = "Official"
senior_official_page.form["title"] = "Director"
senior_official_page.form["email"] = "new_official@example.com"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
senior_official_page.form.submit()
self.assertTrue(self.mock_client.send_email.called)
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("DOMAIN: igorville.gov", body)
self.assertIn("UPDATED BY: First Last info@example.com", body)
self.assertIn("INFORMATION UPDATED: Senior official", body)
@boto3_mocking.patching
@less_console_noise_decorator
def test_no_notification_on_senior_official_when_portfolio(self):
"""Test that an email is not sent when the senior official information is changed
and the domain is in a portfolio."""
self.domain_information.senior_official = Contact.objects.create(
first_name="Old", last_name="Official", title="Manager", email="old_official@example.com"
)
portfolio, _ = Portfolio.objects.get_or_create(
organization_name="portfolio",
creator=self.user,
)
self.domain_information.portfolio = portfolio
self.domain_information.save()
senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
senior_official_page.form["first_name"] = "New"
senior_official_page.form["last_name"] = "Official"
senior_official_page.form["title"] = "Director"
senior_official_page.form["email"] = "new_official@example.com"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
senior_official_page.form.submit()
self.assertFalse(self.mock_client.send_email.called)
@boto3_mocking.patching
@less_console_noise_decorator
def test_no_notification_when_dns_needed(self):
"""Test that an email is not sent when nameservers are changed while the state is DNS_NEEDED."""
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain_dns_needed.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
# add nameservers
nameservers_page.form["form-0-server"] = "ns1-new.dns-needed.gov"
nameservers_page.form["form-0-ip"] = "192.168.1.1"
nameservers_page.form["form-1-server"] = "ns2-new.dns-needed.gov"
nameservers_page.form["form-1-ip"] = "192.168.1.2"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
nameservers_page.form.submit()
# Check that an email was not sent
self.assertFalse(self.mock_client.send_email.called)

View file

@ -22,30 +22,47 @@ class EmailSendingError(RuntimeError):
pass
def send_templated_email(
def send_templated_email( # noqa
template_name: str,
subject_template_name: str,
to_address: str,
bcc_address="",
to_address: str = "",
bcc_address: str = "",
context={},
attachment_file=None,
wrap_email=False,
cc_addresses: list[str] = [],
):
"""Send an email built from a template to one email address.
"""Send an email built from a template.
to_address and bcc_address currently only support single addresses.
cc_address is a list and can contain many addresses. Emails not in the
whitelist (if applicable) will be filtered out before sending.
template_name and subject_template_name are relative to the same template
context as Django's HTML templates. context gives additional information
that the template may use.
Raises EmailSendingError if SES client could not be accessed
Raises EmailSendingError if:
SES client could not be accessed
No valid recipient addresses are provided
"""
# by default assume we can send to all addresses (prod has no whitelist)
sendable_cc_addresses = cc_addresses
if not settings.IS_PRODUCTION: # type: ignore
# Split into a function: C901 'send_templated_email' is too complex.
# Raises an error if we cannot send an email (due to restrictions).
# Does nothing otherwise.
_can_send_email(to_address, bcc_address)
# if we're not in prod, we need to check the whitelist for CC'ed addresses
sendable_cc_addresses, blocked_cc_addresses = get_sendable_addresses(cc_addresses)
if blocked_cc_addresses:
logger.warning("Some CC'ed addresses were removed: %s.", blocked_cc_addresses)
template = get_template(template_name)
email_body = template.render(context=context)
@ -64,14 +81,23 @@ def send_templated_email(
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=settings.BOTO_CONFIG,
)
logger.info(f"An email was sent! Template name: {template_name} to {to_address}")
logger.info(f"Connected to SES client! Template name: {template_name} to {to_address}")
except Exception as exc:
logger.debug("E-mail unable to send! Could not access the SES client.")
raise EmailSendingError("Could not access the SES client.") from exc
destination = {"ToAddresses": [to_address]}
destination = {}
if to_address:
destination["ToAddresses"] = [to_address]
if bcc_address:
destination["BccAddresses"] = [bcc_address]
if cc_addresses:
destination["CcAddresses"] = sendable_cc_addresses
# make sure we don't try and send an email to nowhere
if not destination:
message = "Email unable to send, no valid recipients provided."
raise EmailSendingError(message)
try:
if not attachment_file:
@ -90,6 +116,7 @@ def send_templated_email(
},
},
)
logger.info("Email sent to [%s], bcc [%s], cc %s", to_address, bcc_address, sendable_cc_addresses)
else:
ses_client = boto3.client(
"ses",
@ -101,6 +128,10 @@ def send_templated_email(
send_email_with_attachment(
settings.DEFAULT_FROM_EMAIL, to_address, subject, email_body, attachment_file, ses_client
)
logger.info(
"Email with attachment sent to [%s], bcc [%s], cc %s", to_address, bcc_address, sendable_cc_addresses
)
except Exception as exc:
raise EmailSendingError("Could not send SES email.") from exc
@ -125,6 +156,33 @@ def _can_send_email(to_address, bcc_address):
raise EmailSendingError(message.format(bcc_address))
def get_sendable_addresses(addresses: list[str]) -> tuple[list[str], list[str]]:
"""Checks whether a list of addresses can be sent to.
Returns: a lists of all provided addresses that are ok to send to and a list of addresses that were blocked.
Paramaters:
addresses: a list of strings representing all addresses to be checked.
"""
if flag_is_active(None, "disable_email_sending"): # type: ignore
message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'."
logger.warning(message)
return ([], [])
else:
AllowedEmail = apps.get_model("registrar", "AllowedEmail")
allowed_emails = []
blocked_emails = []
for address in addresses:
if AllowedEmail.is_allowed_email(address):
allowed_emails.append(address)
else:
blocked_emails.append(address)
return allowed_emails, blocked_emails
def wrap_text_and_preserve_paragraphs(text, width):
"""
Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'.

View file

@ -23,6 +23,15 @@ class InvalidDomainError(ValueError):
pass
class OutsideOrgMemberError(ValueError):
"""
Error raised when an org member tries adding a user from a different .gov org.
To be deleted when users can be members of multiple orgs.
"""
pass
class ActionNotAllowed(Exception):
"""User accessed an action that is not
allowed by the current state"""

View file

@ -5,6 +5,7 @@ authorized users can see information on a domain, every view here should
inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView).
"""
from datetime import date
import logging
from django.contrib import messages
@ -21,8 +22,10 @@ from registrar.models import (
DomainRequest,
DomainInformation,
DomainInvitation,
PortfolioInvitation,
User,
UserDomainRole,
UserPortfolioPermission,
PublicContact,
)
from registrar.utility.enums import DefaultEmail
@ -35,9 +38,11 @@ from registrar.utility.errors import (
DsDataErrorCodes,
SecurityEmailError,
SecurityEmailErrorCodes,
OutsideOrgMemberError,
)
from registrar.models.utility.contact_error import ContactError
from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView
from registrar.utility.waffle import flag_is_active_for_user
from ..forms import (
SeniorOfficialContactForm,
@ -148,6 +153,103 @@ class DomainFormBaseView(DomainBaseView, FormMixin):
return current_domain_info
def send_update_notification(self, form, force_send=False):
"""Send a notification to all domain managers that an update has occured
for a single domain. Uses update_to_approved_domain.txt template.
If there are no changes to the form, emails will NOT be sent unless force_send
is set to True.
"""
# send notification email for changes to any of these forms
form_label_dict = {
DomainSecurityEmailForm: "Security email",
DomainDnssecForm: "DNSSEC / DS Data",
DomainDsdataFormset: "DNSSEC / DS Data",
DomainOrgNameAddressForm: "Organization details",
SeniorOfficialContactForm: "Senior official",
NameserverFormset: "Name servers",
}
# forms of these types should not send notifications if they're part of a portfolio/Organization
check_for_portfolio = {
DomainOrgNameAddressForm,
SeniorOfficialContactForm,
}
is_analyst_action = "analyst_action" in self.session and "analyst_action_location" in self.session
should_notify = False
if form.__class__ in form_label_dict:
if is_analyst_action:
logger.debug("No notification sent: Action was conducted by an analyst")
else:
# these types of forms can cause notifications
should_notify = True
if form.__class__ in check_for_portfolio:
# some forms shouldn't cause notifications if they are in a portfolio
info = self.get_domain_info_from_domain()
if not info or info.portfolio:
logger.debug("No notification sent: Domain is part of a portfolio")
should_notify = False
else:
# don't notify for any other types of forms
should_notify = False
if should_notify and (form.has_changed() or force_send):
context = {
"domain": self.object.name,
"user": self.request.user,
"date": date.today(),
"changes": form_label_dict[form.__class__],
}
self.email_domain_managers(
self.object,
"emails/update_to_approved_domain.txt",
"emails/update_to_approved_domain_subject.txt",
context,
)
else:
logger.info(f"No notification sent for {form.__class__}.")
def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
"""Send a single email built from a template to all managers for a given domain.
template_name and subject_template_name are relative to the same template
context as Django's HTML templates. context gives additional information
that the template may use.
context is a dictionary containing any information needed to fill in values
in the provided template, exactly the same as with send_templated_email.
Will log a warning if the email fails to send for any reason, but will not raise an error.
"""
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))
try:
# Remove the current user so they aren't CC'ed, since they will be the "to_address"
emails.remove(self.request.user.email) # type: ignore
except ValueError:
pass
try:
send_templated_email(
template,
subject_template,
to_address=self.request.user.email, # type: ignore
context=context,
cc_addresses=emails,
)
except EmailSendingError:
logger.warning(
"Could not sent notification email to %s for domain %s",
emails,
domain.name,
exc_info=True,
)
class DomainView(DomainBaseView):
"""Domain detail overview page."""
@ -223,6 +325,8 @@ class DomainOrgNameAddressView(DomainFormBaseView):
def form_valid(self, form):
"""The form is valid, save the organization name and mailing address."""
self.send_update_notification(form)
form.save()
messages.success(self.request, "The organization information for this domain has been updated.")
@ -326,6 +430,8 @@ class DomainSeniorOfficialView(DomainFormBaseView):
form.set_domain_info(self.object.domain_info)
form.save()
self.send_update_notification(form)
messages.success(self.request, "The senior official for this domain has been updated.")
# superclass has the redirect
@ -404,19 +510,25 @@ class DomainNameserversView(DomainFormBaseView):
self._get_domain(request)
formset = self.get_form()
logger.debug("got formet")
if "btn-cancel-click" in request.POST:
url = self.get_success_url()
return HttpResponseRedirect(url)
if formset.is_valid():
logger.debug("formset is valid")
return self.form_valid(formset)
else:
logger.debug("formset is invalid")
logger.debug(formset.errors)
return self.form_invalid(formset)
def form_valid(self, formset):
"""The formset is valid, perform something with it."""
self.request.session["nameservers_form_domain"] = self.object
initial_state = self.object.state
# Set the nameservers from the formset
nameservers = []
@ -438,7 +550,6 @@ class DomainNameserversView(DomainFormBaseView):
except KeyError:
# no server information in this field, skip it
pass
try:
self.object.nameservers = nameservers
except NameserverError as Err:
@ -458,6 +569,8 @@ class DomainNameserversView(DomainFormBaseView):
messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA))
logger.error(f"Registry error: {Err}")
else:
if initial_state == Domain.State.READY:
self.send_update_notification(formset)
messages.success(
self.request,
"The name servers for this domain have been updated. "
@ -510,7 +623,8 @@ class DomainDNSSECView(DomainFormBaseView):
errmsg = "Error removing existing DNSSEC record(s)."
logger.error(errmsg + ": " + err)
messages.error(self.request, errmsg)
else:
self.send_update_notification(form, force_send=True)
return self.form_valid(form)
@ -634,6 +748,8 @@ class DomainDsDataView(DomainFormBaseView):
logger.error(f"Registry error: {err}")
return self.form_invalid(formset)
else:
self.send_update_notification(formset)
messages.success(self.request, "The DS data records for this domain have been updated.")
# superclass has the redirect
return super().form_valid(formset)
@ -700,8 +816,12 @@ class DomainSecurityEmailView(DomainFormBaseView):
messages.error(self.request, SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA))
logger.error(f"Generic registry error: {Err}")
else:
self.send_update_notification(form)
messages.success(self.request, "The security email for this domain has been updated.")
# superclass has the redirect
return super().form_valid(form)
# superclass has the redirect
return redirect(self.get_success_url())
@ -778,7 +898,18 @@ class DomainAddUserView(DomainFormBaseView):
"""Get an absolute URL for this domain."""
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id}))
def _send_domain_invitation_email(self, email: str, requestor: User, add_success=True):
def _is_member_of_different_org(self, email, requestor, requested_user):
"""Verifies if an email belongs to a different organization as a member or invited member."""
# Check if user is a already member of a different organization than the requestor's org
requestor_org = UserPortfolioPermission.objects.filter(user=requestor).first().portfolio
existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first()
existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first()
return (existing_org_permission and existing_org_permission.portfolio != requestor_org) or (
existing_org_invitation and existing_org_invitation.portfolio != requestor_org
)
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
"""Performs the sending of the domain invitation email,
does not make a domain information object
email: string- email to send to
@ -803,6 +934,13 @@ class DomainAddUserView(DomainFormBaseView):
)
return None
# Check is user is a member or invited member of a different org from this domain's org
if flag_is_active_for_user(requestor, "organization_feature") and self._is_member_of_different_org(
email, requestor, requested_user
):
add_success = False
raise OutsideOrgMemberError
# Check to see if an invite has already been sent
try:
invite = DomainInvitation.objects.get(email=email, domain=self.object)
@ -859,16 +997,21 @@ class DomainAddUserView(DomainFormBaseView):
Throws EmailSendingError."""
requested_email = form.cleaned_data["email"]
requestor = self.request.user
email_success = False
# look up a user with that email
try:
requested_user = User.objects.get(email=requested_email)
except User.DoesNotExist:
# no matching user, go make an invitation
email_success = True
return self._make_invitation(requested_email, requestor)
else:
# if user already exists then just send an email
try:
self._send_domain_invitation_email(requested_email, requestor, add_success=False)
self._send_domain_invitation_email(
requested_email, requestor, requested_user=requested_user, add_success=False
)
email_success = True
except EmailSendingError:
logger.warn(
"Could not send email invitation (EmailSendingError)",
@ -876,6 +1019,17 @@ class DomainAddUserView(DomainFormBaseView):
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
email_success = True
except OutsideOrgMemberError:
logger.warn(
"Could not send email. Can not invite member of a .gov organization to a different organization.",
self.object,
exc_info=True,
)
messages.error(
self.request,
f"{requested_email} is already a member of another .gov organization.",
)
except Exception:
logger.warn(
"Could not send email invitation (Other Exception)",
@ -883,17 +1037,17 @@ class DomainAddUserView(DomainFormBaseView):
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
if email_success:
try:
UserDomainRole.objects.create(
user=requested_user,
domain=self.object,
role=UserDomainRole.Roles.MANAGER,
)
messages.success(self.request, f"Added user {requested_email}.")
except IntegrityError:
messages.warning(self.request, f"{requested_email} is already a manager for this domain")
try:
UserDomainRole.objects.create(
user=requested_user,
domain=self.object,
role=UserDomainRole.Roles.MANAGER,
)
except IntegrityError:
messages.warning(self.request, f"{requested_email} is already a manager for this domain")
else:
messages.success(self.request, f"Added user {requested_email}.")
return redirect(self.get_success_url())

View file

@ -1,75 +1,68 @@
-i https://pypi.python.org/simple
annotated-types==0.6.0; python_version >= '3.8'
annotated-types==0.7.0; python_version >= '3.8'
asgiref==3.8.1; python_version >= '3.8'
boto3==1.34.95; python_version >= '3.8'
botocore==1.34.95; python_version >= '3.8'
cachetools==5.3.3; python_version >= '3.7'
certifi==2024.2.2; python_version >= '3.6'
boto3==1.35.41; python_version >= '3.8'
botocore==1.35.41; python_version >= '3.8'
cachetools==5.5.0; python_version >= '3.7'
certifi==2024.8.30; python_version >= '3.6'
cfenv==0.5.3
cffi==1.16.0; platform_python_implementation != 'PyPy'
charset-normalizer==3.3.2; python_full_version >= '3.7.0'
cryptography==42.0.5; python_version >= '3.7'
cffi==1.17.1; platform_python_implementation != 'PyPy'
charset-normalizer==3.4.0; python_full_version >= '3.7.0'
cryptography==43.0.1; python_version >= '3.7'
defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
diff-match-patch==20230430; python_version >= '3.7'
dj-database-url==2.1.0
dj-database-url==2.2.0
dj-email-url==1.0.6
django==4.2.10; python_version >= '3.8'
django-admin-multiple-choice-list-filter==0.1.1
django-allow-cidr==0.7.1
django-auditlog==3.0.0; python_version >= '3.8'
django-cache-url==3.4.5
django-cors-headers==4.3.1; python_version >= '3.8'
django-cors-headers==4.5.0; python_version >= '3.9'
django-csp==3.8
django-fsm==2.8.1
django-import-export==3.3.8; python_version >= '3.8'
django-import-export==4.1.1; python_version >= '3.8'
django-login-required-middleware==0.9.0
django-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8'
django-phonenumber-field[phonenumberslite]==8.0.0; python_version >= '3.8'
django-waffle==4.1.0; python_version >= '3.8'
django-widget-tweaks==1.5.0; python_version >= '3.8'
environs[django]==11.0.0; python_version >= '3.8'
et-xmlfile==1.1.0; python_version >= '3.6'
faker==25.0.0; python_version >= '3.8'
faker==30.3.0; python_version >= '3.8'
fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c
furl==2.1.3
future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
gevent==24.2.1; python_version >= '3.8'
greenlet==3.0.3; python_version >= '3.7'
gunicorn==22.0.0; python_version >= '3.7'
idna==3.7; python_version >= '3.5'
gevent==24.10.2; python_version >= '3.9'
greenlet==3.1.1; python_version >= '3.7'
gunicorn==23.0.0; python_version >= '3.7'
idna==3.10; python_version >= '3.6'
jmespath==1.0.1; python_version >= '3.7'
lxml==5.2.1; python_version >= '3.6'
mako==1.3.3; python_version >= '3.8'
markuppy==1.14
markupsafe==2.1.5; python_version >= '3.7'
marshmallow==3.21.1; python_version >= '3.8'
odfpy==1.4.1
lxml==5.3.0; python_version >= '3.6'
mako==1.3.5; python_version >= '3.8'
markupsafe==3.0.1; python_version >= '3.9'
marshmallow==3.22.0; python_version >= '3.8'
oic==1.7.0; python_version ~= '3.8'
openpyxl==3.1.2
orderedmultidict==1.0.1
packaging==24.0; python_version >= '3.7'
phonenumberslite==8.13.35
packaging==24.1; python_version >= '3.8'
phonenumberslite==8.13.47
psycopg2-binary==2.9.9; python_version >= '3.7'
pycparser==2.22; python_version >= '3.8'
pycryptodomex==3.20.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
pydantic==2.7.1; python_version >= '3.8'
pydantic-core==2.18.2; python_version >= '3.8'
pydantic-settings==2.2.1; python_version >= '3.8'
pycryptodomex==3.21.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
pydantic==2.9.2; python_version >= '3.8'
pydantic-core==2.23.4; python_version >= '3.8'
pydantic-settings==2.5.2; python_version >= '3.8'
pyjwkest==1.4.2
python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
python-dotenv==1.0.1; python_version >= '3.8'
pyyaml==6.0.1
pyzipper==0.3.6; python_version >= '3.4'
requests==2.31.0; python_version >= '3.7'
s3transfer==0.10.1; python_version >= '3.8'
setuptools==69.5.1; python_version >= '3.8'
requests==2.32.3; python_version >= '3.8'
s3transfer==0.10.3; python_version >= '3.8'
setuptools==75.1.0; python_version >= '3.8'
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
sqlparse==0.5.0; python_version >= '3.8'
sqlparse==0.5.1; python_version >= '3.8'
tablib[html,ods,xls,xlsx,yaml]==3.5.0; python_version >= '3.8'
tblib==3.0.0; python_version >= '3.8'
typing-extensions==4.11.0; python_version >= '3.8'
urllib3==2.2.1; python_version >= '3.8'
whitenoise==6.6.0; python_version >= '3.8'
xlrd==2.0.1
xlwt==1.3.0
typing-extensions==4.12.2; python_version >= '3.8'
urllib3==2.2.3; python_version >= '3.8'
whitenoise==6.7.0; python_version >= '3.8'
zope.event==5.0; python_version >= '3.7'
zope.interface==6.3; python_version >= '3.7'
zope.interface==7.1.0; python_version >= '3.8'