merge main

This commit is contained in:
Rachid Mrad 2024-07-22 15:39:46 -04:00
commit faddfcec50
No known key found for this signature in database
54 changed files with 1207 additions and 659 deletions

View file

@ -19,12 +19,13 @@ There are several tools we use locally that you will need to have.
- If you are using Windows, installation information can be found [here](https://github.com/cloudfoundry/cli/wiki/V8-CLI-Installation-Guide#installers-and-compressed-binaries) - If you are using Windows, installation information can be found [here](https://github.com/cloudfoundry/cli/wiki/V8-CLI-Installation-Guide#installers-and-compressed-binaries)
- Alternatively, for Windows, [consider using chocolately](https://community.chocolatey.org/packages/cloudfoundry-cli/7.2.0) - Alternatively, for Windows, [consider using chocolately](https://community.chocolatey.org/packages/cloudfoundry-cli/7.2.0)
- [ ] Make sure you have `gpg` >2.1.7. Run `gpg --version` to check. If not, [install gnupg](https://formulae.brew.sh/formula/gnupg) - [ ] Make sure you have `gpg` >2.1.7. Run `gpg --version` to check. If not, [install gnupg](https://formulae.brew.sh/formula/gnupg)
- Alternatively, you can skip this step and [use ssh keys](#setting-up-commit-signing-with-ssh) instead
- [ ] Install the [Github CLI](https://cli.github.com/) - [ ] Install the [Github CLI](https://cli.github.com/)
## Access ## Access
### Steps for the onboardee ### Steps for the onboardee
- [ ] Setup [commit signing in Github](#setting-up-commit-signing) and with git locally. - [ ] Setup commit signing in Github and with git locally using either [gpg](#setting-up-commit-signing-with-gpg) or [ssh](#setting-up-commit-signing-with-ssh).
- [ ] [Create a cloud.gov account](https://cloud.gov/docs/getting-started/accounts/) - [ ] [Create a cloud.gov account](https://cloud.gov/docs/getting-started/accounts/)
- [ ] Email github@cisa.dhs.gov (cc: Cameron) to add you to the [CISA Github organization](https://github.com/getgov) and [.gov Team](https://github.com/orgs/cisagov/teams/gov). - [ ] Email github@cisa.dhs.gov (cc: Cameron) to add you to the [CISA Github organization](https://github.com/getgov) and [.gov Team](https://github.com/orgs/cisagov/teams/gov).
- [ ] Ensure you can login to your cloud.gov account via the CLI - [ ] Ensure you can login to your cloud.gov account via the CLI
@ -51,7 +52,7 @@ cf login -a api.fr.cloud.gov --sso
- [ ] [Contributing Policy](https://github.com/cisagov/dotgov/tree/main/CONTRIBUTING.md) - [ ] [Contributing Policy](https://github.com/cisagov/dotgov/tree/main/CONTRIBUTING.md)
## Setting up commit signing ## Setting up commit signing with GPG
Follow the instructions [here](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) to generate a new GPG key (default configurations are okay) and add it to your GPG keys on Github. Follow the instructions [here](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) to generate a new GPG key (default configurations are okay) and add it to your GPG keys on Github.
@ -72,6 +73,22 @@ when setting up your key in Github.
Now test commit signing is working by checking out a branch (`yourname/test-commit-signing`) and making some small change to a file. Commit the change (it should prompt you for your GPG credential) and push it to Github. Look on Github at your branch and ensure the commit is `verified`. Now test commit signing is working by checking out a branch (`yourname/test-commit-signing`) and making some small change to a file. Commit the change (it should prompt you for your GPG credential) and push it to Github. Look on Github at your branch and ensure the commit is `verified`.
## Setting up commit signing with SSH
Follow the instructions [here](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent#generating-a-new-ssh-key) to generate a new SSH key and [add it to your SSH keys on Github](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account). Note that you need to add the key as a signing key.
Configure your key locally:
```bash
git config --global gpg.format ssh
git config --global commit.gpgsign true
git config --global user.signingkey <YOUR KEY>
```
Where `<YOUR KEY>` is the path to the private key you generated when running `ssh-keygen`. Usually this is located in ~\.ssh\.
Now test commit signing is working by checking out a branch (`yourinitials/test-commit-signing`) and making some small change to a file. Commit the change (it should prompt you for your key passphrase) and push it to Github. Look on Github at your branch and ensure the commit is `verified`.
### MacOS ### MacOS
**Note:** if you are on a mac and not able to successfully create a signed commit, getting the following error: **Note:** if you are on a mac and not able to successfully create a signed commit, getting the following error:
```zsh ```zsh

View file

@ -28,6 +28,7 @@ on:
- ab - ab
- rjm - rjm
- dk - dk
- ms
jobs: jobs:
createcachetable: createcachetable:

View file

@ -0,0 +1,92 @@
# Manually deploy a branch of choice to an environment of choice.
name: Manual Build and Deploy
run-name: Manually build and deploy branch to sandbox of choice
on:
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy'
required: true
default: 'backup'
type: 'choice'
options:
- ab
- backup
- cb
- dk
- es
- gd
- ko
- ky
- nl
- rb
- rh
- rjm
- meoward
- bob
- hotgov
- litterbox
- ms
# GitHub Actions has no "good" way yet to dynamically input branches
branch:
description: 'Branch to deploy'
required: true
default: 'main'
type: string
jobs:
variables:
runs-on: ubuntu-latest
steps:
- name: Setting global variables
uses: actions/github-script@v6
id: var
with:
script: |
core.setOutput('environment', '${{ github.head_ref }}'.split("/")[0]);
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Compile USWDS assets
working-directory: ./src
run: |
docker compose run node npm install npm@latest &&
docker compose run node npm install &&
docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile
- name: Collect static assets
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
- name: Deploy to cloud.gov sandbox
uses: cloud-gov/cg-cli-tools@main
env:
ENVIRONMENT: ${{ github.event.inputs.environment }}
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ env.ENVIRONMENT }}
cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml
comment:
runs-on: ubuntu-latest
needs: [deploy]
steps:
- uses: actions/github-script@v6
env:
ENVIRONMENT: ${{ github.event.inputs.environment }}
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.'
})

View file

@ -22,6 +22,7 @@ jobs:
- name: Compile USWDS assets - name: Compile USWDS assets
working-directory: ./src working-directory: ./src
run: | run: |
docker compose run node npm install npm@latest &&
docker compose run node npm install && docker compose run node npm install &&
docker compose run node npx gulp copyAssets && docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile docker compose run node npx gulp compile

View file

@ -47,6 +47,7 @@ jobs:
- name: Compile USWDS assets - name: Compile USWDS assets
working-directory: ./src working-directory: ./src
run: | run: |
docker compose run node npm install npm@latest &&
docker compose run node npm install && docker compose run node npm install &&
docker compose run node npx gulp copyAssets && docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile docker compose run node npx gulp compile

View file

@ -0,0 +1,18 @@
name: Notify users based on issue labels
on:
issues:
types: [labeled]
pull_request:
types: [labeled]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- uses: jenschelkopf/issue-label-notification-action@1.3
with:
recipients: |
design-review=@Katherine-Osos
message: 'cc/ {recipients} — adding you to this **{label}** issue!'

View file

@ -429,6 +429,10 @@ class ViewsTest(TestCase):
# Create a mock request # Create a mock request
request = self.factory.get("/some-url") request = self.factory.get("/some-url")
request.session = {"acr_value": ""} request.session = {"acr_value": ""}
# Mock user and its attributes
mock_user = MagicMock()
mock_user.is_authenticated = True
request.user = mock_user
# Ensure that the CLIENT instance used in login_callback is the mock # Ensure that the CLIENT instance used in login_callback is the mock
# patch _requires_step_up_auth to return False # patch _requires_step_up_auth to return False
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(

View file

@ -1,4 +1,3 @@
version: "3.0"
services: services:
app: app:
build: . build: .

View file

@ -741,6 +741,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"last_name", "last_name",
"title", "title",
"email", "email",
"phone",
"Permissions", "Permissions",
"is_active", "is_active",
"groups", "groups",
@ -1383,7 +1384,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
for name, data in fieldsets: for name, data in fieldsets:
fields = data.get("fields", []) fields = data.get("fields", [])
fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields) fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields)
modified_fieldsets.append((name, {"fields": fields})) modified_fieldsets.append((name, {**data, "fields": fields}))
return modified_fieldsets return modified_fieldsets
return fieldsets return fieldsets
@ -1644,7 +1645,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history", "status_history",
"action_needed_reason_email",
) )
# Read only that we'll leverage for CISA Analysts # Read only that we'll leverage for CISA Analysts
@ -1698,7 +1698,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
for name, data in fieldsets: for name, data in fieldsets:
fields = data.get("fields", []) fields = data.get("fields", [])
fields = tuple(field for field in fields if field not in self.superuser_only_fields) fields = tuple(field for field in fields if field not in self.superuser_only_fields)
modified_fieldsets.append((name, {"fields": fields})) modified_fieldsets.append((name, {**data, "fields": fields}))
return modified_fieldsets return modified_fieldsets
return fieldsets return fieldsets
@ -1745,19 +1745,33 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if not change: if not change:
return super().save_model(request, obj, form, change) return super().save_model(request, obj, form, change)
# == Handle non-status changes == #
# Change this in #1901. Add a check on "not self.action_needed_reason_email"
if obj.action_needed_reason:
self._handle_action_needed_reason_email(obj)
should_save = True
# Get the original domain request from the database. # Get the original domain request from the database.
original_obj = models.DomainRequest.objects.get(pk=obj.pk) original_obj = models.DomainRequest.objects.get(pk=obj.pk)
# == Handle action_needed_reason == #
reason_changed = obj.action_needed_reason != original_obj.action_needed_reason
if reason_changed:
# Track the fact that we sent out an email
request.session["action_needed_email_sent"] = True
# Set the action_needed_reason_email to the default if nothing exists.
# Since this check occurs after save, if the user enters a value then we won't update.
default_email = self._get_action_needed_reason_default_email(obj, obj.action_needed_reason)
if obj.action_needed_reason_email:
emails = self.get_all_action_needed_reason_emails(obj)
is_custom_email = obj.action_needed_reason_email not in emails.values()
if not is_custom_email:
obj.action_needed_reason_email = default_email
else:
obj.action_needed_reason_email = default_email
# == Handle status == #
if obj.status == original_obj.status: if obj.status == original_obj.status:
# If the status hasn't changed, let the base function take care of it # If the status hasn't changed, let the base function take care of it
return super().save_model(request, obj, form, change) return super().save_model(request, obj, form, change)
else:
# == Handle status changes == #
# Run some checks on the current object for invalid status changes # Run some checks on the current object for invalid status changes
obj, should_save = self._handle_status_change(request, obj, original_obj) obj, should_save = self._handle_status_change(request, obj, original_obj)
@ -1765,10 +1779,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if should_save: if should_save:
return super().save_model(request, obj, form, change) return super().save_model(request, obj, form, change)
def _handle_action_needed_reason_email(self, obj):
text = self._get_action_needed_reason_default_email_text(obj, obj.action_needed_reason)
obj.action_needed_reason_email = text.get("email_body_text")
def _handle_status_change(self, request, obj, original_obj): def _handle_status_change(self, request, obj, original_obj):
""" """
Checks for various conditions when a status change is triggered. Checks for various conditions when a status change is triggered.
@ -1962,49 +1972,54 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Initialize extra_context and add filtered entries # Initialize extra_context and add filtered entries
extra_context = extra_context or {} extra_context = extra_context or {}
extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries
extra_context["action_needed_reason_emails"] = self.get_all_action_needed_reason_emails_as_json(obj) emails = self.get_all_action_needed_reason_emails(obj)
extra_context["action_needed_reason_emails"] = json.dumps(emails)
extra_context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature") extra_context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
# Denote if an action needed email was sent or not
email_sent = request.session.get("action_needed_email_sent", False)
extra_context["action_needed_email_sent"] = email_sent
if email_sent:
request.session["action_needed_email_sent"] = False
# Call the superclass method with updated extra_context # Call the superclass method with updated extra_context
return super().change_view(request, object_id, form_url, extra_context) return super().change_view(request, object_id, form_url, extra_context)
def get_all_action_needed_reason_emails_as_json(self, domain_request): def get_all_action_needed_reason_emails(self, domain_request):
"""Returns a json dictionary of every action needed reason and its associated email """Returns a json dictionary of every action needed reason and its associated email
for this particular domain request.""" for this particular domain request."""
emails = {} emails = {}
for action_needed_reason in domain_request.ActionNeededReasons: for action_needed_reason in domain_request.ActionNeededReasons:
enum_value = action_needed_reason.value # Map the action_needed_reason to its default email
# Change this in #1901. Just add a check for the current value. emails[action_needed_reason.value] = self._get_action_needed_reason_default_email(
emails[enum_value] = self._get_action_needed_reason_default_email_text(domain_request, enum_value) domain_request, action_needed_reason.value
return json.dumps(emails) )
def _get_action_needed_reason_default_email_text(self, domain_request, action_needed_reason: str): return emails
def _get_action_needed_reason_default_email(self, domain_request, action_needed_reason):
"""Returns the default email associated with the given action needed reason""" """Returns the default email associated with the given action needed reason"""
if action_needed_reason is None or action_needed_reason == domain_request.ActionNeededReasons.OTHER: if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER:
return { return None
"subject_text": None,
"email_body_text": None,
}
# Get the email body
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
template = get_template(template_path)
# Get the email subject
template_subject_path = f"emails/action_needed_reasons/{action_needed_reason}_subject.txt"
subject_template = get_template(template_subject_path)
if flag_is_active(None, "profile_feature"): # type: ignore if flag_is_active(None, "profile_feature"): # type: ignore
recipient = domain_request.creator recipient = domain_request.creator
else: else:
recipient = domain_request.submitter recipient = domain_request.submitter
# Return the content of the rendered views # Return the context of the rendered views
context = {"domain_request": domain_request, "recipient": recipient} context = {"domain_request": domain_request, "recipient": recipient}
return {
"subject_text": subject_template.render(context=context), # Get the email body
"email_body_text": template.render(context=context), template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
}
email_body_text = get_template(template_path).render(context=context)
email_body_text_cleaned = None
if email_body_text:
email_body_text_cleaned = email_body_text.strip().lstrip("\n")
return email_body_text_cleaned
def process_log_entry(self, log_entry): def process_log_entry(self, log_entry):
"""Process a log entry and return filtered entry dictionary if applicable.""" """Process a log entry and return filtered entry dictionary if applicable."""

View file

@ -36,6 +36,15 @@ function openInNewTab(el, removeAttribute = false){
} }
}; };
// Adds or removes a boolean from our session
function addOrRemoveSessionBoolean(name, add){
if (add) {
sessionStorage.setItem(name, "true");
}else {
sessionStorage.removeItem(name);
}
}
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Event handlers. // Event handlers.
@ -420,15 +429,6 @@ function initializeWidgetOnList(list, parentId) {
object.classList.add("display-none"); object.classList.add("display-none");
} }
} }
// Adds or removes a boolean from our session
function addOrRemoveSessionBoolean(name, add){
if (add) {
sessionStorage.setItem(name, "true");
}else {
sessionStorage.removeItem(name);
}
}
})(); })();
/** An IIFE for toggling the submit bar on domain request forms /** An IIFE for toggling the submit bar on domain request forms
@ -528,54 +528,80 @@ function initializeWidgetOnList(list, parentId) {
* This shows the auto generated email on action needed reason. * This shows the auto generated email on action needed reason.
*/ */
(function () { (function () {
let actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason"); // Since this is an iife, these vars will be removed from memory afterwards
let actionNeededEmail = document.querySelector("#action_needed_reason_email_view_more"); var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
if(actionNeededReasonDropdown && actionNeededEmail) { var actionNeededEmail = document.querySelector("#id_action_needed_reason_email");
// Add a change listener to the action needed reason dropdown var readonlyView = document.querySelector("#action-needed-reason-email-readonly");
handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail);
}
function handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail) { let emailWasSent = document.getElementById("action-needed-email-sent");
actionNeededReasonDropdown.addEventListener("change", function() { let actionNeededEmailData = document.getElementById('action-needed-emails-data').textContent;
let actionNeededEmailsJson = JSON.parse(actionNeededEmailData);
const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null
const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`;
const oldDropdownValue = actionNeededReasonDropdown ? actionNeededReasonDropdown.value : null;
const oldEmailValue = actionNeededEmailData ? actionNeededEmailData.value : null;
if(actionNeededReasonDropdown && actionNeededEmail && domainRequestId) {
// Add a change listener to dom load
document.addEventListener('DOMContentLoaded', function() {
let reason = actionNeededReasonDropdown.value; let reason = actionNeededReasonDropdown.value;
// If a reason isn't specified, no email will be sent. // Handle the session boolean (to enable/disable editing)
// You also cannot save the model in this state. if (emailWasSent && emailWasSent.value === "True") {
// This flow occurs if you switch back to the empty picker state. // An email was sent out - store that information in a session variable
if(!reason) { addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true);
showNoEmailMessage(actionNeededEmail);
return;
} }
let actionNeededEmails = JSON.parse(document.getElementById('action-needed-emails-data').textContent) // Show an editable email field or a readonly one
let emailData = actionNeededEmails[reason]; updateActionNeededEmailDisplay(reason)
if (emailData) { });
let emailBody = emailData.email_body_text
if (emailBody) { // Add a change listener to the action needed reason dropdown
actionNeededEmail.value = emailBody actionNeededReasonDropdown.addEventListener("change", function() {
showActionNeededEmail(actionNeededEmail); let reason = actionNeededReasonDropdown.value;
}else { let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null;
showNoEmailMessage(actionNeededEmail); if (reason && emailBody) {
// Replace the email content
actionNeededEmail.value = emailBody;
// Reset the session object on change since change refreshes the email content.
if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) {
let emailSent = sessionStorage.getItem(emailSentSessionVariableName)
if (emailSent !== null){
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=false)
}
} }
}else {
showNoEmailMessage(actionNeededEmail);
} }
// Show an editable email field or a readonly one
updateActionNeededEmailDisplay(reason)
}); });
} }
// Show the text field. Hide the "no email" message. // Shows an editable email field or a readonly one.
function showActionNeededEmail(actionNeededEmail){ // If the email doesn't exist or if we're of reason "other", display that no email was sent.
let noEmailMessage = document.getElementById("no-email-message"); // Likewise, if we've sent this email before, we should just display the content.
showElement(actionNeededEmail); function updateActionNeededEmailDisplay(reason) {
hideElement(noEmailMessage); let emailHasBeenSentBefore = sessionStorage.getItem(emailSentSessionVariableName) !== null;
let collapseableDiv = readonlyView.querySelector(".collapse--dgsimple");
let showMoreButton = document.querySelector("#action_needed_reason_email__show_details");
if ((reason && reason != "other") && !emailHasBeenSentBefore) {
showElement(actionNeededEmail.parentElement)
hideElement(readonlyView)
hideElement(showMoreButton)
} else {
if (!reason || reason === "other") {
collapseableDiv.innerHTML = reason ? "No email will be sent." : "-";
hideElement(showMoreButton)
if (collapseableDiv.classList.contains("collapsed")) {
showMoreButton.click()
}
}else {
showElement(showMoreButton)
}
hideElement(actionNeededEmail.parentElement)
showElement(readonlyView)
} }
// Hide the text field. Show the "no email" message.
function showNoEmailMessage(actionNeededEmail) {
let noEmailMessage = document.getElementById("no-email-message");
hideElement(actionNeededEmail);
showElement(noEmailMessage);
} }
})(); })();

View file

@ -1140,6 +1140,7 @@ document.addEventListener('DOMContentLoaded', function() {
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]'); const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
const statusIndicator = document.querySelector('.domain__filter-indicator'); const statusIndicator = document.querySelector('.domain__filter-indicator');
const statusToggle = document.querySelector('.usa-button--filter'); const statusToggle = document.querySelector('.usa-button--filter');
const noPortfolioFlag = document.getElementById('no-portfolio-js-flag');
/** /**
* Loads rows in the domains list, as well as updates pagination around the domains list * Loads rows in the domains list, as well as updates pagination around the domains list
@ -1173,8 +1174,20 @@ document.addEventListener('DOMContentLoaded', function() {
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url; const actionUrl = domain.action_url;
const suborganization = domain.suborganization ? domain.suborganization : '';
const row = document.createElement('tr'); const row = document.createElement('tr');
let markupForSuborganizationRow = '';
if (!noPortfolioFlag) {
markupForSuborganizationRow = `
<td>
<span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span>
</td>
`
}
row.innerHTML = ` row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name"> <th scope="row" role="rowheader" data-label="Domain name">
${domain.name} ${domain.name}
@ -1195,6 +1208,7 @@ document.addEventListener('DOMContentLoaded', function() {
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use> <use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
</svg> </svg>
</td> </td>
${markupForSuborganizationRow}
<td> <td>
<a href="${actionUrl}"> <a href="${actionUrl}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
@ -1826,6 +1840,9 @@ document.addEventListener('DOMContentLoaded', function() {
} }
function setupListener(){ function setupListener(){
document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) {
// Get the "{field_name}" and "edit-button" // Get the "{field_name}" and "edit-button"
let fieldIdParts = button.id.split("__") let fieldIdParts = button.id.split("__")
@ -1834,12 +1851,61 @@ document.addEventListener('DOMContentLoaded', function() {
// When the edit button is clicked, show the input field under it // When the edit button is clicked, show the input field under it
handleEditButtonClick(fieldName, button); handleEditButtonClick(fieldName, button);
let editableFormGroup = button.parentElement.parentElement.parentElement;
if (editableFormGroup){
let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field")
let inputField = document.getElementById(`id_${fieldName}`);
if (!inputField || !readonlyField) {
return;
}
let inputFieldValue = inputField.value
if (inputFieldValue || fieldName == "full_name"){
if (fieldName == "full_name"){
let firstName = document.querySelector("#id_first_name");
let middleName = document.querySelector("#id_middle_name");
let lastName = document.querySelector("#id_last_name");
if (firstName && lastName && firstName.value && lastName.value) {
let values = [firstName.value, middleName.value, lastName.value]
readonlyField.innerHTML = values.join(" ");
}else {
let fullNameField = document.querySelector('#full_name__edit-button-readonly');
let svg = fullNameField.querySelector("svg use")
if (svg) {
const currentHref = svg.getAttribute('xlink:href');
if (currentHref) {
const parts = currentHref.split('#');
if (parts.length === 2) {
// Keep the path before '#' and replace the part after '#' with 'invalid'
const newHref = parts[0] + '#error';
svg.setAttribute('xlink:href', newHref);
fullNameField.classList.add("input-with-edit-button__error")
label = fullNameField.querySelector(".input-with-edit-button__readonly-field")
label.innerHTML = "Unknown";
}
}
}
}
// Technically, the full_name field is optional, but we want to display it as required.
// This style is applied to readonly fields (gray text). This just removes it, as
// this is difficult to achieve otherwise by modifying the .readonly property.
if (readonlyField.classList.contains("text-base")) {
readonlyField.classList.remove("text-base")
}
}else {
readonlyField.innerHTML = inputFieldValue
}
}
}
} }
}); });
} }
function showInputOnErrorFields(){ function showInputOnErrorFields(){
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Get all input elements within the form // Get all input elements within the form
let form = document.querySelector("#finish-profile-setup-form"); let form = document.querySelector("#finish-profile-setup-form");
let inputs = form ? form.querySelectorAll("input") : null; let inputs = form ? form.querySelectorAll("input") : null;
@ -1878,9 +1944,9 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}; };
// Hookup all edit buttons to the `handleEditButtonClick` function
setupListener(); setupListener();
// Show the input fields if an error exists // Show the input fields if an error exists
showInputOnErrorFields(); showInputOnErrorFields();
})(); })();

View file

@ -29,52 +29,14 @@ body {
#wrapper.dashboard { #wrapper.dashboard {
background-color: color('primary-lightest'); background-color: color('primary-lightest');
padding-top: units(5); padding-top: units(5)!important;
} }
.usa-logo { #wrapper.dashboard--portfolio {
@include at-media(desktop) { background-color: color('gray-1');
margin-top: units(2); padding-top: units(4)!important;
}
} }
.usa-logo__text {
@include typeset('sans', 'xl', 2);
color: color('primary-darker');
}
.usa-nav__primary {
margin-top:units(1);
}
.usa-nav__primary-username {
display: inline-block;
padding: units(1) units(2);
max-width: 208px;
overflow: hidden;
text-overflow: ellipsis;
@include at-media(desktop) {
padding: units(2);
max-width: 500px;
}
}
@include at-media(desktop) {
.usa-nav__primary-item:not(:first-child) {
position: relative;
}
.usa-nav__primary-item:not(:first-child)::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 0; /* No width since it's a border */
height: 40%;
border-left: solid 1px color('base-light');
transform: translateY(-50%);
}
}
.section--outlined { .section--outlined {
background-color: color('white'); background-color: color('white');
@ -136,10 +98,6 @@ footer {
color: color('primary'); color: color('primary');
} }
.usa-identifier__logo {
height: units(7);
}
abbr[title] { abbr[title] {
// workaround for underlining abbr element // workaround for underlining abbr element
border-bottom: none; border-bottom: none;
@ -179,47 +137,35 @@ abbr[title] {
cursor: pointer; cursor: pointer;
} }
.input-with-edit-button {
svg.usa-icon {
width: 1.5em !important;
height: 1.5em !important;
color: #{$dhs-green};
position: absolute;
}
&.input-with-edit-button__error {
svg.usa-icon {
color: #{$dhs-red};
}
div.readonly-field {
color: #{$dhs-red};
}
}
}
// We need to deviate from some default USWDS styles here
// in this particular case, so we have to override this.
.usa-form .usa-button.readonly-edit-button {
margin-top: 0px !important;
padding-top: 0px !important;
svg {
width: 1.25em !important;
height: 1.25em !important;
}
}
// Define some styles for the .gov header/logo
.usa-logo button {
color: #{$dhs-dark-gray-85};
font-weight: 700;
font-family: family('sans');
font-size: 1.6rem;
line-height: 1.1;
}
.usa-logo button.usa-button--unstyled.disabled-button:hover{
color: #{$dhs-dark-gray-85};
}
.padding--8-8-9 { .padding--8-8-9 {
padding: 8px 8px 9px !important; padding: 8px 8px 9px !important;
} }
.ellipsis {
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ellipsis--23 {
max-width: 23ch;
}
.ellipsis--30 {
max-width: 30ch;
}
.ellipsis--50 {
max-width: 50ch;
}
.vertical-align-middle {
vertical-align: middle;
}
@include at-media(desktop) {
.ellipsis--desktop-50 {
max-width: 50ch;
}
}

View file

@ -162,6 +162,34 @@ a.usa-button--unstyled:visited {
} }
} }
.input-with-edit-button {
svg.usa-icon {
width: 1.5em !important;
height: 1.5em !important;
color: #{$dhs-green};
position: absolute;
}
&.input-with-edit-button__error {
svg.usa-icon {
color: #{$dhs-red};
}
div.readonly-field {
color: #{$dhs-red};
}
}
}
// We need to deviate from some default USWDS styles here
// in this particular case, so we have to override this.
.usa-form .usa-button.readonly-edit-button {
margin-top: 0px !important;
padding-top: 0px !important;
svg {
width: 1.25em !important;
height: 1.25em !important;
}
}
.usa-button--filter { .usa-button--filter {
width: auto; width: auto;
// For mobile stacking // For mobile stacking

View file

@ -0,0 +1,121 @@
@use "uswds-core" as *;
@use "cisa_colors" as *;
// Define some styles for the .gov header/logo
.usa-logo button {
color: #{$dhs-dark-gray-85};
font-weight: 700;
font-family: family('sans');
font-size: 1.6rem;
line-height: 1.1;
}
.usa-logo button:hover{
color: #{$dhs-dark-gray-85};
}
.usa-header {
.usa-logo {
@include at-media(desktop) {
margin-top: units(2);
}
}
.usa-logo__text {
@include typeset('sans', 'xl', 2);
}
.usa-nav__username {
max-width: 208px;
min-height: units(2);
@include at-media(desktop) {
max-width: 500px;
}
}
.padding-y-0 {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
}
.usa-header--basic {
.usa-logo__text {
color: color('primary-darker');
}
.usa-nav__username {
padding: units(1) units(2);
@include at-media(desktop) {
padding: units(2);
}
}
.usa-nav__primary {
margin-top:units(1);
}
@include at-media(desktop) {
.usa-nav__primary-item:not(:first-child) {
position: relative;
}
.usa-nav__primary-item:not(:first-child)::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 0; /* No width since it's a border */
height: 40%;
border-left: solid 1px color('base-light');
transform: translateY(-50%);
}
}
}
.usa-header--extended {
@include at-media(desktop) {
background-color: color('primary-darker');
border-top: solid 1px color('base-light');
border-bottom: solid 1px color('base-lighter');
.usa-logo__text a,
.usa-logo__text button,
.usa-logo__text button:hover {
color: color('white');
}
.usa-nav {
background-color: color('primary-lightest');
}
.usa-nav__primary-item:last-child {
margin-left: auto;
.usa-nav-link {
margin-right: units(-2);
}
}
.usa-nav__primary {
.usa-nav-link,
.usa-nav-link:hover,
.usa-nav-link:active {
color: color('primary');
font-weight: font-weight('normal');
font-size: 16px;
}
.usa-current,
.usa-current:hover,
.usa-current:active {
font-weight: font-weight('bold');
}
}
.usa-nav__secondary {
// I don't know why USWDS has this at 2 rem, which puts it out of alignment
right: 3rem;
color: color('white');
bottom: 4.3rem;
.usa-nav-link,
.usa-nav-link:hover,
.usa-nav-link:active {
font-weight: font-weight('bold');
color: color('primary-lighter');
font-size: 16px;
}
}
> .usa-navbar {
// This is a dangerous override to USWDS, necessary because we have a tooltip on the logo
overflow: visible;
}
}
}

View file

@ -0,0 +1,9 @@
@use "uswds-core" as *;
.usa-banner {
background-color: color('primary-darker');
}
.usa-identifier__logo {
height: units(7);
}

View file

@ -1,7 +1,7 @@
@use "uswds-core" as *; @use "uswds-core" as *;
.dotgov-table { .dotgov-table a,
a { .usa-link--icon {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
color: color('primary'); color: color('primary');
@ -9,10 +9,6 @@
&:visited { &:visited {
color: color('primary'); color: color('primary');
} }
}
}
a {
.usa-icon { .usa-icon {
// align icon with x height // align icon with x height
margin-top: units(0.5); margin-top: units(0.5);

View file

@ -80,46 +80,3 @@
} }
} }
} }
@media (min-width: 1040px){
.domain-requests__table {
th:nth-of-type(1) {
width: 200px;
}
th:nth-of-type(2) {
width: 158px;
}
th:nth-of-type(3) {
width: 120px;
}
th:nth-of-type(4) {
width: 95px;
}
th:nth-of-type(5) {
width: 85px;
}
}
}
@media (min-width: 1040px){
.domains__table {
th:nth-of-type(1) {
width: 200px;
}
th:nth-of-type(2) {
width: 158px;
}
th:nth-of-type(3) {
width: 215px;
}
th:nth-of-type(4) {
width: 95px;
}
}
}

View file

@ -21,6 +21,8 @@
@forward "alerts"; @forward "alerts";
@forward "tables"; @forward "tables";
@forward "sidenav"; @forward "sidenav";
@forward "identifier";
@forward "header";
@forward "register-form"; @forward "register-form";
/*-------------------------------------------------- /*--------------------------------------------------

View file

@ -240,6 +240,10 @@ TEMPLATES = [
"registrar.context_processors.canonical_path", "registrar.context_processors.canonical_path",
"registrar.context_processors.is_demo_site", "registrar.context_processors.is_demo_site",
"registrar.context_processors.is_production", "registrar.context_processors.is_production",
"registrar.context_processors.org_user_status",
"registrar.context_processors.add_portfolio_to_context",
"registrar.context_processors.add_path_to_context",
"registrar.context_processors.add_has_profile_feature_flag_to_context",
"registrar.context_processors.portfolio_permissions", "registrar.context_processors.portfolio_permissions",
], ],
}, },

View file

@ -1,4 +1,5 @@
from django.conf import settings from django.conf import settings
from waffle.decorators import flag_is_active
def language_code(request): def language_code(request):
@ -38,6 +39,29 @@ def is_production(request):
return {"IS_PRODUCTION": settings.IS_PRODUCTION} return {"IS_PRODUCTION": settings.IS_PRODUCTION}
def org_user_status(request):
if request.user.is_authenticated:
is_org_user = request.user.is_org_user(request)
else:
is_org_user = False
return {
"is_org_user": is_org_user,
}
def add_portfolio_to_context(request):
return {"portfolio": getattr(request, "portfolio", None)}
def add_path_to_context(request):
return {"path": getattr(request, "path", None)}
def add_has_profile_feature_flag_to_context(request):
return {"has_profile_feature_flag": flag_is_active(request, "profile_feature")}
def portfolio_permissions(request): def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context""" """Make portfolio permissions for the request user available in global context"""
try: try:

View file

@ -71,7 +71,7 @@ class UserProfileForm(forms.ModelForm):
class FinishSetupProfileForm(UserProfileForm): class FinishSetupProfileForm(UserProfileForm):
"""Form for updating user profile.""" """Form for updating user profile."""
full_name = forms.CharField(required=True, label="Full name") full_name = forms.CharField(required=False, label="Full name")
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
@ -93,4 +93,7 @@ class FinishSetupProfileForm(UserProfileForm):
self.fields["title"].label = "Title or role in your organization" self.fields["title"].label = "Title or role in your organization"
# Define the "full_name" value # Define the "full_name" value
self.fields["full_name"].initial = self.instance.get_formatted_name() full_name = None
if self.instance.first_name and self.instance.last_name:
full_name = self.instance.get_formatted_name()
self.fields["full_name"].initial = full_name

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Union from typing import Union
import logging import logging
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
@ -619,8 +618,9 @@ class DomainRequest(TimeStampedModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Handle the action needed email. We send one when moving to action_needed, # Handle the action needed email.
# but we don't send one when we are _already_ in the state and change the reason. # An email is sent out when action_needed_reason is changed or added.
if self.action_needed_reason and self.status == self.DomainRequestStatus.ACTION_NEEDED:
self.sync_action_needed_reason() self.sync_action_needed_reason()
# Update the cached values after saving # Update the cached values after saving
@ -631,10 +631,10 @@ class DomainRequest(TimeStampedModel):
was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED
reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None
reason_changed = self._cached_action_needed_reason != self.action_needed_reason reason_changed = self._cached_action_needed_reason != self.action_needed_reason
if was_already_action_needed and (reason_exists and reason_changed): if was_already_action_needed and reason_exists and reason_changed:
# We don't send emails out in state "other" # We don't send emails out in state "other"
if self.action_needed_reason != self.ActionNeededReasons.OTHER: if self.action_needed_reason != self.ActionNeededReasons.OTHER:
self._send_action_needed_reason_email() self._send_action_needed_reason_email(email_content=self.action_needed_reason_email)
def sync_yes_no_form_fields(self): def sync_yes_no_form_fields(self):
"""Some yes/no forms use a db field to track whether it was checked or not. """Some yes/no forms use a db field to track whether it was checked or not.
@ -688,7 +688,15 @@ class DomainRequest(TimeStampedModel):
logger.error(f"Can't query an approved domain while attempting {called_from}") logger.error(f"Can't query an approved domain while attempting {called_from}")
def _send_status_update_email( def _send_status_update_email(
self, new_status, email_template, email_template_subject, send_email=True, bcc_address="", wrap_email=False self,
new_status,
email_template,
email_template_subject,
bcc_address="",
context=None,
send_email=True,
wrap_email=False,
custom_email_content=None,
): ):
"""Send a status update email to the creator. """Send a status update email to the creator.
@ -699,11 +707,18 @@ class DomainRequest(TimeStampedModel):
If the waffle flag "profile_feature" is active, then this email will be sent to the If the waffle flag "profile_feature" is active, then this email will be sent to the
domain request creator rather than the submitter domain request creator rather than the submitter
Optional args:
bcc_address: str -> the address to bcc to
context: dict -> The context sent to the template
send_email: bool -> Used to bypass the send_templated_email function, in the event send_email: bool -> Used to bypass the send_templated_email function, in the event
we just want to log that an email would have been sent, rather than actually sending one. we just want to log that an email would have been sent, rather than actually sending one.
wrap_email: bool -> Wraps emails using `wrap_text_and_preserve_paragraphs` if any given wrap_email: bool -> Wraps emails using `wrap_text_and_preserve_paragraphs` if any given
paragraph exceeds our desired max length (for prettier display). paragraph exceeds our desired max length (for prettier display).
custom_email_content: str -> Renders an email with the content of this string as its body text.
""" """
recipient = self.creator if flag_is_active(None, "profile_feature") else self.submitter recipient = self.creator if flag_is_active(None, "profile_feature") else self.submitter
@ -721,15 +736,21 @@ class DomainRequest(TimeStampedModel):
return None return None
try: try:
send_templated_email( if not context:
email_template,
email_template_subject,
recipient.email,
context = { context = {
"domain_request": self, "domain_request": self,
# This is the user that we refer to in the email # This is the user that we refer to in the email
"recipient": recipient, "recipient": recipient,
}, }
if custom_email_content:
context["custom_email_content"] = custom_email_content
send_templated_email(
email_template,
email_template_subject,
recipient.email,
context=context,
bcc_address=bcc_address, bcc_address=bcc_address,
wrap_email=wrap_email, wrap_email=wrap_email,
) )
@ -787,8 +808,8 @@ class DomainRequest(TimeStampedModel):
"submission confirmation", "submission confirmation",
"emails/submission_confirmation.txt", "emails/submission_confirmation.txt",
"emails/submission_confirmation_subject.txt", "emails/submission_confirmation_subject.txt",
True, send_email=True,
bcc_address, bcc_address=bcc_address,
) )
@transition( @transition(
@ -858,41 +879,26 @@ class DomainRequest(TimeStampedModel):
# Send out an email if an action needed reason exists # Send out an email if an action needed reason exists
if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER: if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER:
self._send_action_needed_reason_email(send_email) email_content = self.action_needed_reason_email
self._send_action_needed_reason_email(send_email, email_content)
def _send_action_needed_reason_email(self, send_email=True): def _send_action_needed_reason_email(self, send_email=True, email_content=None):
"""Sends out an automatic email for each valid action needed reason provided""" """Sends out an automatic email for each valid action needed reason provided"""
# Store the filenames of the template and template subject email_template_name = "custom_email.txt"
email_template_name: str = ""
email_template_subject_name: str = ""
# Check for the "type" of action needed reason.
can_send_email = True
match self.action_needed_reason:
# Add to this match if you need to pass in a custom filename for these templates.
case self.ActionNeededReasons.OTHER, _:
# Unknown and other are default cases - do nothing
can_send_email = False
# Assumes that the template name matches the action needed reason if nothing is specified.
# This is so you can override if you need, or have this taken care of for you.
if not email_template_name and not email_template_subject_name:
email_template_name = f"{self.action_needed_reason}.txt"
email_template_subject_name = f"{self.action_needed_reason}_subject.txt" email_template_subject_name = f"{self.action_needed_reason}_subject.txt"
bcc_address = "" bcc_address = ""
if settings.IS_PRODUCTION: if settings.IS_PRODUCTION:
bcc_address = settings.DEFAULT_FROM_EMAIL bcc_address = settings.DEFAULT_FROM_EMAIL
# If we can, try to send out an email as long as send_email=True
if can_send_email:
self._send_status_update_email( self._send_status_update_email(
new_status="action needed", new_status="action needed",
email_template=f"emails/action_needed_reasons/{email_template_name}", email_template=f"emails/action_needed_reasons/{email_template_name}",
email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}", email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}",
send_email=send_email, send_email=send_email,
bcc_address=bcc_address, bcc_address=bcc_address,
custom_email_content=email_content,
wrap_email=True, wrap_email=True,
) )
@ -952,7 +958,7 @@ class DomainRequest(TimeStampedModel):
"domain request approved", "domain request approved",
"emails/status_change_approved.txt", "emails/status_change_approved.txt",
"emails/status_change_approved_subject.txt", "emails/status_change_approved_subject.txt",
send_email, send_email=send_email,
) )
@transition( @transition(

View file

@ -4,6 +4,7 @@ from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from registrar.models.portfolio import Portfolio
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from .domain_invitation import DomainInvitation from .domain_invitation import DomainInvitation
@ -12,6 +13,7 @@ from .verified_by_staff import VerifiedByStaff
from .domain import Domain from .domain import Domain
from .domain_request import DomainRequest from .domain_request import DomainRequest
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from waffle.decorators import flag_is_active
from phonenumber_field.modelfields import PhoneNumberField # type: ignore from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -195,7 +197,8 @@ class User(AbstractUser):
self.title, self.title,
self.phone, self.phone,
] ]
return None not in user_values
return None not in user_values and "" not in user_values
def __str__(self): def __str__(self):
# this info is pulled from Login.gov # this info is pulled from Login.gov
@ -410,3 +413,9 @@ class User(AbstractUser):
""" """
self.check_domain_invitations_on_login() self.check_domain_invitations_on_login()
def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature")
user_portfolios_exist = Portfolio.objects.filter(creator=self).exists()
return has_organization_feature_flag and user_portfolios_exist

View file

@ -140,14 +140,13 @@ class CheckPortfolioMiddleware:
def process_view(self, request, view_func, view_args, view_kwargs): def process_view(self, request, view_func, view_args, view_kwargs):
current_path = request.path current_path = request.path
has_organization_feature_flag = flag_is_active(request, "organization_feature") if current_path == self.home and request.user.is_authenticated and request.user.is_org_user(request):
if current_path == self.home:
if has_organization_feature_flag:
if request.user.is_authenticated:
if request.user.has_base_portfolio_permission(): if request.user.has_base_portfolio_permission():
portfolio = request.user.portfolio portfolio = request.user.portfolio0
# Add the portfolio to the request object
request.portfolio = portfolio
if request.user.has_domains_portfolio_permission(): if request.user.has_domains_portfolio_permission():
portfolio_redirect = reverse("portfolio-domains", kwargs={"portfolio_id": portfolio.id}) portfolio_redirect = reverse("portfolio-domains", kwargs={"portfolio_id": portfolio.id})

View file

@ -133,48 +133,10 @@
</section> </section>
{% block usa_overlay %}<div class="usa-overlay"></div>{% endblock %} <div class="usa-overlay"></div>
{% block banner %} {% block header %}
<header class="usa-header usa-header--basic"> {% include "includes/header_selector.html" with logo_clickable=True %}
<div class="usa-nav-container"> {% endblock header %}
<div class="usa-navbar">
{% block logo %}
{% include "includes/gov_extended_logo.html" with logo_clickable=True %}
{% endblock %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation">
<button type="button" class="usa-nav__close">
<img src="/public/img/usa-icons/close.svg" role="img" alt="Close" />
</button>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if user.is_authenticated %}
<span class="usa-nav__primary-username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__primary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if request.path == user_profile_url or request.path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
<span class="text-primary">Your profile</span>
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="{% url 'logout' %}"><span class="text-primary">Sign out</span></a>
{% else %}
<a href="{% url 'login' %}"><span>Sign in</span></a>
{% endif %}
</li>
</ul>
</nav>
{% block usa_nav_secondary %}{% endblock %}
{% endblock %}
</div>
</header>
{% endblock banner %}
{% block wrapper %} {% block wrapper %}
<div id="wrapper"> <div id="wrapper">

View file

@ -143,6 +143,28 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endwith %} {% endwith %}
{% endblock field_readonly %} {% endblock field_readonly %}
{% block field_other %}
{% if field.field.name == "action_needed_reason_email" %}
<div id="action-needed-reason-email-readonly" class="readonly margin-top-0 padding-top-0 display-none">
<div class="margin-top-05 collapse--dgsimple collapsed">
{{ field.field.value|linebreaks }}
</div>
<button id="action_needed_reason_email__show_details" type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-0 margin-bottom-1 margin-left-1">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
<div>
{{ field.field }}
<input id="action-needed-email-sent" class="display-none" value="{{action_needed_email_sent}}">
</div>
{% else %}
{{ field.field }}
{% endif %}
{% endblock field_other %}
{% block after_help_text %} {% block after_help_text %}
{% if field.field.name == "action_needed_reason_email" %} {% if field.field.name == "action_needed_reason_email" %}
{% comment %} {% comment %}

View file

@ -0,0 +1,3 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
{{ custom_email_content }}
{% endautoescape %}

View file

@ -42,13 +42,11 @@ Your domain request was rejected because we determined that {{ domain_request.or
eligible for a .gov domain. .Gov domains are only available to official U.S.-based eligible for a .gov domain. .Gov domains are only available to official U.S.-based
government organizations. government organizations.
Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>.
DEMONSTRATE ELIGIBILITY If you have questions or comments, reply to this email.
If you can provide documentation that demonstrates your eligibility, reply to this email. {% elif domain_request.rejection_reason == 'naming_not_met' %}
This can include links to (or copies of) your authorizing legislation, your founding
charter or bylaws, or other similar documentation. Without this, we cant approve a
.gov domain for your organization. Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>.{% elif domain_request.rejection_reason == 'naming_not_met' %}
Your domain request was rejected because it does not meet our naming requirements. Your domain request was rejected because it does not meet our naming requirements.
Domains should uniquely identify a government organization and be clear to the Domains should uniquely identify a government organization and be clear to the
general public. Learn more about naming requirements for your type of organization general public. Learn more about naming requirements for your type of organization

View file

@ -4,8 +4,8 @@
{% block title %} Finish setting up your profile | {% endblock %} {% block title %} Finish setting up your profile | {% endblock %}
{# Disable the redirect #} {# Disable the redirect #}
{% block logo %} {% block header %}
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %} {% include "includes/header_selector.html" with logo_clickable=user_finished_setup %}
{% endblock %} {% endblock %}
{# Add the new form #} {# Add the new form #}

View file

@ -10,11 +10,11 @@
{# the entire logged in page goes here #} {# the entire logged in page goes here #}
{% block homepage_content %} {% block homepage_content %}
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1"> <div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
{% block messages %} {% block messages %}
{% include "includes/form_messages.html" %} {% include "includes/form_messages.html" %}
{% endblock %} {% endblock %}
<h1>Manage your domains</h1> <h1>Manage your domains</h1>
{% comment %} {% comment %}
@ -32,26 +32,8 @@
{% include "includes/domains_table.html" %} {% include "includes/domains_table.html" %}
{% include "includes/domain_requests_table.html" %} {% include "includes/domain_requests_table.html" %}
{# Note: Reimplement this after MVP #}
<!--
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
<h2>Archived domains</h2>
<p>You don't have any archived domains</p>
</section>
-->
<!-- Note: Uncomment below when this is being implemented post-MVP -->
<!-- <section class="tablet:grid-col-11 desktop:grid-col-10">
<h2 class="padding-top-1 mobile-lg:padding-top-3"> Export domains</h2>
<p>Download a list of your domains and their statuses as a csv file.</p>
<a href="{% url 'todo' %}" class="usa-button usa-button--outline">
Export domains as csv
</a>
</section>
-->
{% endblock %}
</div> </div>
{% endblock %}
{% else %} {# not user.is_authenticated #} {% else %} {# not user.is_authenticated #}
{# the entire logged out page goes here #} {# the entire logged out page goes here #}

View file

@ -2,6 +2,7 @@
<section class="section--outlined domain-requests" id="domain-requests"> <section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row"> <div class="grid-row">
<!-- Use portfolio_base_permission when merging into 2366 and then delete this comment -->
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2> <h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
@ -12,6 +13,9 @@
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button"> <button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset Reset
</button> </button>
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label> <label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>

View file

@ -2,16 +2,21 @@
<section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains"> <section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
<div class="grid-row"> <div class="grid-row">
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domains-header" class="flex-6">Domains</h2> <h2 id="domains-header" class="flex-6">Domains</h2>
</div> </div>
<span class="display-none" id="no-portfolio-js-flag"></span>
{% endif %} {% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domains search component" class="flex-6 margin-y-2"> <section aria-label="Domains search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-search display-none" type="button"> <button class="usa-button usa-button--unstyled margin-right-3 domains__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset Reset
</button> </button>
<label class="usa-sr-only" for="domains__search-field">Search by domain name</label> <label class="usa-sr-only" for="domains__search-field">Search by domain name</label>
@ -33,9 +38,10 @@
</section> </section>
</div> </div>
</div> </div>
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio %} {% if portfolio %}
<div class="display-flex flex-align-center margin-top-1"> <div class="display-flex flex-align-center margin-top-1">
<span class="margin-right-2 margin-top-neg-1 text-base-darker">Filter by</span> <span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2"> <div class="usa-accordion usa-accordion--select margin-right-2">
<div class="usa-accordion__heading"> <div class="usa-accordion__heading">
<button <button
@ -136,6 +142,10 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th> <th data-sortable="name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th> <th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</th> <th data-sortable="state_display" scope="col" role="columnheader">Status</th>
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio %}
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
{% endif %}
<th <th
scope="col" scope="col"
role="columnheader" role="columnheader"
@ -156,7 +166,7 @@
<div class="domains__no-data display-none"> <div class="domains__no-data display-none">
<p>You don't have any registered domains.</p> <p>You don't have any registered domains.</p>
<p class="maxw-none clearfix"> <p class="maxw-none clearfix">
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank"> <a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet usa-link usa-link--icon" target="_blank">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use> <use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
</svg> </svg>

View file

@ -0,0 +1,39 @@
{% load static %}
<header class="usa-header usa-header--basic">
<div class="usa-nav-container">
<div class="usa-navbar">
{% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation">
<button type="button" class="usa-nav__close">
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if user.is_authenticated %}
<span class="usa-nav__username ellipsis">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__primary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if request.path == user_profile_url or request.path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
<span class="text-primary">Your profile</span>
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="{% url 'logout' %}"><span class="text-primary">Sign out</span></a>
{% else %}
<a href="{% url 'login' %}"><span>Sign in</span></a>
{% endif %}
</li>
</ul>
</nav>
{% block usa_nav_secondary %}{% endblock %}
{% endblock %}
</div>
</header>

View file

@ -0,0 +1,76 @@
{% load static %}
<header class="usa-header usa-header--extended">
<div class="usa-navbar">
{% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation">
<div class="usa-nav__inner">
<button type="button" class="usa-nav__close">
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button>
<ul class="usa-nav__primary usa-accordion">
{% if has_domains_portfolio_permission %}
<li class="usa-nav__primary-item">
{% url 'portfolio-domains' portfolio.id as url %}
<a href="{{ url }}" class="usa-nav-link{% if request.path == url %} usa-current{% endif %}">
Domains
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
Domain groups
</a>
</li>
{% if has_domain_requests_portfolio_permission %}
<li class="usa-nav__primary-item">
{% url 'portfolio-domain-requests' portfolio.id as url %}
<a href="{{ url }}" class="usa-nav-link{% if request.path == url %} usa-current{% endif %}">
Domain requests
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
Members
</a>
</li>
<li class="usa-nav__primary-item">
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
<a href="#" class="usa-nav-link padding-y-0">
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
{{ portfolio.organization_name }}
</span>
</a>
</li>
</ul>
<div class="usa-nav__secondary">
<ul class="usa-nav__secondary-links">
<li class="usa-nav__secondary-item">
{% if user.is_authenticated %}
<span class="ellipsis usa-nav__username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__secondary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if path == user_profile_url or path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
Your profile
</a>
</li>
{% endif %}
<li class="usa-nav__secondary-item">
<a class="usa-nav-link" href="{% url 'logout' %}">Sign out</a>
{% else %}
<a class="usa-nav-link" href="{% url 'login' %}">Sign in</a>
{% endif %}
</li>
</ul>
</div>
</div>
</nav>
{% endblock %}
</header>

View file

@ -0,0 +1,5 @@
{% if not is_org_user %}
{% include "includes/header_basic.html" with logo_clickable=logo_clickable %}
{% else %}
{% include "includes/header_extended.html" with logo_clickable=logo_clickable %}
{% endif %}

View file

@ -8,7 +8,7 @@
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use> <use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
{%endif %} {%endif %}
</svg> </svg>
<div class="display-inline padding-left-05 margin-left-3 readonly-field {% if not field.field.required %}text-base{% endif %}"> <div class="display-inline padding-left-05 margin-left-3 input-with-edit-button__readonly-field {% if not field.field.required %}text-base{% endif %}">
{% if field.name != "phone" %} {% if field.name != "phone" %}
{{ field.value }} {{ field.value }}
{% else %} {% else %}

View file

@ -112,8 +112,11 @@
<div class="text-right"> <div class="text-right">
<a <a
href="{{ edit_link }}" href="{{ edit_link }}"
class="usa-link font-sans-sm" class="usa-link usa-link--icon font-sans-sm line-height-sans-5"
> >
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{% static 'img/sprite.svg' %}#edit"></use>
</svg>
Edit<span class="sr-only"> {{ title }}</span> Edit<span class="sr-only"> {{ title }}</span>
</a> </a>
</div> </div>

View file

@ -1,24 +0,0 @@
{% extends 'home.html' %}
{% load static %}
{% block homepage_content %}
<div class="tablet:grid-col-12">
<div class="grid-row grid-gap">
<div class="tablet:grid-col-3">
{% include "portfolio_sidebar.html" with portfolio=portfolio %}
</div>
<div class="tablet:grid-col-9">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
{# Note: Reimplement commented out functionality #}
{% block portfolio_content %}
{% endblock %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block wrapper %}
<div id="wrapper" class="dashboard--portfolio">
{% block content %}
<main id="main-content" class="grid-container">
{% if user.is_authenticated %}
{# the entire logged in page goes here #}
<div class="tablet:grid-col-12">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
{% block portfolio_content %}{% endblock %}
</div>
{% else %} {# not user.is_authenticated #}
{# the entire logged out page goes here #}
<p><a class="usa-button" href="{% url 'login' %}">
Sign in
</a></p>
{% endif %}
</main>
{% endblock %}
<div role="complementary">{% block complementary %}{% endblock %}</div>
{% block content_bottom %}{% endblock %}
</div>
{% endblock wrapper %}

View file

@ -1,7 +1,9 @@
{% extends 'portfolio.html' %} {% extends 'portfolio_base.html' %}
{% load static %} {% load static %}
{% block title %} Domains | {% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<h1 id="domains-header">Domains</h1> <h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio %} {% include "includes/domains_table.html" with portfolio=portfolio %}

View file

@ -1,7 +1,9 @@
{% extends 'portfolio.html' %} {% extends 'portfolio_base.html' %}
{% load static %} {% load static %}
{% block title %} Domain requests | {% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<h1 id="domain-requests-header">Domain requests</h1> <h1 id="domain-requests-header">Domain requests</h1>

View file

@ -1,45 +0,0 @@
{% load static url_helpers %}
<div class="margin-bottom-4 tablet:margin-bottom-0">
<nav aria-label="">
<h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2>
<ul class="usa-sidenav usa-sidenav--portfolio">
{% if has_domains_portfolio_permission %}
<li class="usa-sidenav__item">
{% url 'portfolio-domains' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
Domains
</a>
</li>
{% endif %}
<li class="usa-sidenav__item">
{% url 'portfolio-organization' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
Organization
</a>
</li>
{% if has_domain_requests_portfolio_permission %}
<li class="usa-sidenav__item">
{% url 'portfolio-domain-requests' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
Domain requests
</a>
</li>
{% endif %}
<li class="usa-sidenav__item">
<a href="#">
Members
</a>
</li>
<li class="usa-sidenav__item">
<a href="#">
Senior official
</a>
</li>
</ul>
</nav>
</div>

View file

@ -6,8 +6,8 @@ Edit your User Profile |
{% load static url_helpers %} {% load static url_helpers %}
{# Disable the redirect #} {# Disable the redirect #}
{% block logo %} {% block header %}
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %} {% include "includes/header_selector.html" with logo_clickable=user_finished_setup %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View file

@ -1399,13 +1399,18 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "status in [submitted,in review,action needed]", count=1) self.assertContains(response, "status in [submitted,in review,action needed]", count=1)
@less_console_noise_decorator @less_console_noise_decorator
def transition_state_and_send_email(self, domain_request, status, rejection_reason=None, action_needed_reason=None): def transition_state_and_send_email(
self, domain_request, status, rejection_reason=None, action_needed_reason=None, action_needed_reason_email=None
):
"""Helper method for the email test cases.""" """Helper method for the email test cases."""
with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
# Create a mock request # Create a mock request
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
# Create a fake session to hook to
request.session = {}
# Modify the domain request's properties # Modify the domain request's properties
domain_request.status = status domain_request.status = status
@ -1415,6 +1420,9 @@ class TestDomainRequestAdmin(MockEppLib):
if action_needed_reason: if action_needed_reason:
domain_request.action_needed_reason = action_needed_reason domain_request.action_needed_reason = action_needed_reason
if action_needed_reason_email:
domain_request.action_needed_reason_email = action_needed_reason_email
# Use the model admin's save_model method # Use the model admin's save_model method
self.admin.save_model(request, domain_request, form=None, change=True) self.admin.save_model(request, domain_request, form=None, change=True)
@ -1468,6 +1476,7 @@ class TestDomainRequestAdmin(MockEppLib):
# Test the email sent out for already_has_domains # Test the email sent out for already_has_domains
already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains)
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@ -1487,7 +1496,7 @@ class TestDomainRequestAdmin(MockEppLib):
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Test the email sent out for questionable_so # Test that a custom email is sent out for questionable_so
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so) self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so)
self.assert_email_is_accurate( self.assert_email_is_accurate(
@ -1502,6 +1511,43 @@ class TestDomainRequestAdmin(MockEppLib):
# Should be unchanged from before # Should be unchanged from before
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
# Tests if an analyst can override existing email content
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
self.transition_state_and_send_email(
domain_request,
action_needed,
action_needed_reason=questionable_so,
action_needed_reason_email="custom email content",
)
domain_request.refresh_from_db()
self.assert_email_is_accurate("custom email content", 4, EMAIL, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
# Tests if a new email gets sent when just the email is changed.
# An email should NOT be sent out if we just modify the email content.
self.transition_state_and_send_email(
domain_request,
action_needed,
action_needed_reason=questionable_so,
action_needed_reason_email="dummy email content",
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
# Set the request back to in review
domain_request.in_review()
# Try sending another email when changing states AND including content
self.transition_state_and_send_email(
domain_request,
action_needed,
action_needed_reason=eligibility_unclear,
action_needed_reason_email="custom content when starting anew",
)
self.assert_email_is_accurate("custom content when starting anew", 5, EMAIL, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 6)
def test_save_model_sends_submitted_email(self): def test_save_model_sends_submitted_email(self):
"""When transitioning to submitted from started or withdrawn on a domain request, """When transitioning to submitted from started or withdrawn on a domain request,
an email is sent out. an email is sent out.
@ -2242,8 +2288,8 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "When a domain request is in ineligible status") self.assertContains(response, "When a domain request is in ineligible status")
self.assertContains(response, "Yes, select ineligible status") self.assertContains(response, "Yes, select ineligible status")
@less_console_noise_decorator
def test_readonly_when_restricted_creator(self): def test_readonly_when_restricted_creator(self):
with less_console_noise():
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
domain_request.creator.status = User.RESTRICTED domain_request.creator.status = User.RESTRICTED
@ -2261,7 +2307,6 @@ class TestDomainRequestAdmin(MockEppLib):
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history", "status_history",
"action_needed_reason_email",
"id", "id",
"created_at", "created_at",
"updated_at", "updated_at",
@ -2323,7 +2368,6 @@ class TestDomainRequestAdmin(MockEppLib):
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history", "status_history",
"action_needed_reason_email",
"creator", "creator",
"about_your_organization", "about_your_organization",
"requested_domain", "requested_domain",
@ -2355,7 +2399,6 @@ class TestDomainRequestAdmin(MockEppLib):
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history", "status_history",
"action_needed_reason_email",
] ]
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)
@ -2425,6 +2468,8 @@ class TestDomainRequestAdmin(MockEppLib):
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
request.user = self.superuser request.user = self.superuser
request.session = {}
# Define a custom implementation for is_active # Define a custom implementation for is_active
def custom_is_active(self): def custom_is_active(self):
return domain_is_active # Override to return True return domain_is_active # Override to return True

View file

@ -47,10 +47,10 @@ class CsvReportsTest(MockDb):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,\r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
] ]
# We don't actually want to write anything for a test case, # We don't actually want to write anything for a test case,
# we just want to verify what is being written. # we just want to verify what is being written.
@ -69,12 +69,12 @@ class CsvReportsTest(MockDb):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,\r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("adomain2.gov,Interstate,,,,,\r\n"), call("adomain2.gov,Interstate,,,,,(blank)\r\n"),
call("zdomain12.gov,Interstate,,,,,\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"),
] ]
# We don't actually want to write anything for a test case, # We don't actually want to write anything for a test case,
# we just want to verify what is being written. # we just want to verify what is being written.
@ -208,6 +208,7 @@ class ExportDataTest(MockDb, MockEppLib):
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_data_type(self): def test_domain_data_type(self):
"""Shows security contacts, domain managers, so""" """Shows security contacts, domain managers, so"""
self.maxDiff = None
# Add security email information # Add security email information
self.domain_1.name = "defaultsecurity.gov" self.domain_1.name = "defaultsecurity.gov"
self.domain_1.save() self.domain_1.save()
@ -233,29 +234,30 @@ class ExportDataTest(MockDb, MockEppLib):
expected_content = ( expected_content = (
"Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO," "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO,"
"SO email,Security contact email,Domain managers,Invited domain managers\n" "SO email,Security contact email,Domain managers,Invited domain managers\n"
"cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,, ,,," "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,,"
"meoward@rocks.com,\n" "meoward@rocks.com,\n"
"defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,," "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,"
', ,,dotgov@cisa.dhs.gov,"meoward@rocks.com, info@example.com, big_lebowski@dude.co",' ',,,(blank),"meoward@rocks.com, info@example.com, big_lebowski@dude.co",'
"woofwardthethird@rocks.com\n" "woofwardthethird@rocks.com\n"
"adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,,," "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,"
"squeaker@rocks.com\n" "squeaker@rocks.com\n"
"bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n" "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n" "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n" "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,," "ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,,"
"security@mail.gov,,\n" "security@mail.gov,,\n"
"sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n" "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n" "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n" "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,registrar@dotgov.gov," "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,,"
"meoward@rocks.com,squeaker@rocks.com\n" "meoward@rocks.com,squeaker@rocks.com\n"
"zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,,meoward@rocks.com,\n" "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -285,17 +287,18 @@ class ExportDataTest(MockDb, MockEppLib):
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,\n" "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,dotgov@cisa.dhs.gov\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
"adomain2.gov,Interstate,,,,,registrar@dotgov.gov\n" "adomain2.gov,Interstate,,,,,(blank)\n"
"zdomain12.gov,Interstate,,,,,\n" "zdomain12.gov,Interstate,,,,,(blank)\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -325,15 +328,16 @@ class ExportDataTest(MockDb, MockEppLib):
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,\n" "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,dotgov@cisa.dhs.gov\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -402,6 +406,7 @@ class ExportDataTest(MockDb, MockEppLib):
squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers).
She should show twice in this report but not in test_DomainManaged.""" She should show twice in this report but not in test_DomainManaged."""
self.maxDiff = None
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
# Call the export functions # Call the export functions

View file

@ -538,6 +538,49 @@ class FinishUserProfileTests(TestWithUser, WebTest):
self._set_session_cookie() self._set_session_cookie()
return page.follow() if follow else page return page.follow() if follow else page
@less_console_noise_decorator
@override_flag("profile_feature", active=True)
def test_full_name_initial_value(self):
"""Test that full_name initial value is empty when first_name or last_name is empty.
This will later be displayed as "unknown" using javascript."""
self.app.set_user(self.incomplete_regular_user.username)
# Test when first_name is empty
self.incomplete_regular_user.first_name = ""
self.incomplete_regular_user.last_name = "Doe"
self.incomplete_regular_user.save()
finish_setup_page = self.app.get(reverse("home")).follow()
form = finish_setup_page.form
self.assertEqual(form["full_name"].value, "")
# Test when last_name is empty
self.incomplete_regular_user.first_name = "John"
self.incomplete_regular_user.last_name = ""
self.incomplete_regular_user.save()
finish_setup_page = self.app.get(reverse("home")).follow()
form = finish_setup_page.form
self.assertEqual(form["full_name"].value, "")
# Test when both first_name and last_name are empty
self.incomplete_regular_user.first_name = ""
self.incomplete_regular_user.last_name = ""
self.incomplete_regular_user.save()
finish_setup_page = self.app.get(reverse("home")).follow()
form = finish_setup_page.form
self.assertEqual(form["full_name"].value, "")
# Test when both first_name and last_name are present
self.incomplete_regular_user.first_name = "John"
self.incomplete_regular_user.last_name = "Doe"
self.incomplete_regular_user.save()
finish_setup_page = self.app.get(reverse("home")).follow()
form = finish_setup_page.form
self.assertEqual(form["full_name"].value, "John Doe")
@less_console_noise_decorator @less_console_noise_decorator
def test_new_user_with_profile_feature_on(self): def test_new_user_with_profile_feature_on(self):
"""Tests that a new user is redirected to the profile setup page when profile_feature is on""" """Tests that a new user is redirected to the profile setup page when profile_feature is on"""
@ -576,6 +619,49 @@ class FinishUserProfileTests(TestWithUser, WebTest):
completed_setup_page = self.app.get(reverse("home")) completed_setup_page = self.app.get(reverse("home"))
self.assertContains(completed_setup_page, "Manage your domain") self.assertContains(completed_setup_page, "Manage your domain")
@less_console_noise_decorator
def test_new_user_with_empty_name_can_add_name(self):
"""Tests that a new user without a name can still enter this information accordingly"""
self.incomplete_regular_user.first_name = ""
self.incomplete_regular_user.last_name = ""
self.incomplete_regular_user.save()
self.app.set_user(self.incomplete_regular_user.username)
with override_flag("profile_feature", active=True):
# This will redirect the user to the setup page.
# Follow implicity checks if our redirect is working.
finish_setup_page = self.app.get(reverse("home")).follow()
self._set_session_cookie()
# Assert that we're on the right page
self.assertContains(finish_setup_page, "Finish setting up your profile")
finish_setup_page = self._submit_form_webtest(finish_setup_page.form)
self.assertEqual(finish_setup_page.status_code, 200)
# We're missing a phone number, so the page should tell us that
self.assertContains(finish_setup_page, "Enter your phone number.")
# Check for the name of the save button
self.assertContains(finish_setup_page, "user_setup_save_button")
# Add a phone number
finish_setup_form = finish_setup_page.form
finish_setup_form["first_name"] = "test"
finish_setup_form["last_name"] = "test2"
finish_setup_form["phone"] = "(201) 555-0123"
finish_setup_form["title"] = "CEO"
finish_setup_form["last_name"] = "example"
save_page = self._submit_form_webtest(finish_setup_form, follow=True)
self.assertEqual(save_page.status_code, 200)
self.assertContains(save_page, "Your profile has been updated.")
# Try to navigate back to the home page.
# This is the same as clicking the back button.
completed_setup_page = self.app.get(reverse("home"))
self.assertContains(completed_setup_page, "Manage your domain")
@less_console_noise_decorator @less_console_noise_decorator
def test_new_user_goes_to_domain_request_with_profile_feature_on(self): def test_new_user_goes_to_domain_request_with_profile_feature_on(self):
"""Tests that a new user is redirected to the domain request page when profile_feature is on""" """Tests that a new user is redirected to the domain request page when profile_feature is on"""

View file

@ -18,6 +18,7 @@ from django.contrib.postgres.aggregates import StringAgg
from registrar.models.utility.generic_helper import convert_queryset_to_dict from registrar.models.utility.generic_helper import convert_queryset_to_dict
from registrar.templatetags.custom_filters import get_region from registrar.templatetags.custom_filters import get_region
from registrar.utility.constants import BranchChoices from registrar.utility.constants import BranchChoices
from registrar.utility.enums import DefaultEmail
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -371,6 +372,15 @@ class DomainExport(BaseExport):
if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL:
domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}"
security_contact_email = model.get("security_contact_email")
invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value}
if (
not security_contact_email
or not isinstance(security_contact_email, str)
or security_contact_email.lower().strip() in invalid_emails
):
security_contact_email = "(blank)"
# create a dictionary of fields which can be included in output. # create a dictionary of fields which can be included in output.
# "extra_fields" are precomputed fields (generated in the DB or parsed). # "extra_fields" are precomputed fields (generated in the DB or parsed).
FIELDS = { FIELDS = {
@ -385,7 +395,7 @@ class DomainExport(BaseExport):
"State": model.get("state_territory"), "State": model.get("state_territory"),
"SO": model.get("so_name"), "SO": model.get("so_name"),
"SO email": model.get("senior_official__email"), "SO email": model.get("senior_official__email"),
"Security contact email": model.get("security_contact_email"), "Security contact email": security_contact_email,
"Created at": model.get("domain__created_at"), "Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"), "Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"), "Domain managers": model.get("managers"),

View file

@ -46,6 +46,10 @@ def send_templated_email(
template = get_template(template_name) template = get_template(template_name)
email_body = template.render(context=context) email_body = template.render(context=context)
# Do cleanup on the email body. For emails with custom content.
if email_body:
email_body.strip().lstrip("\n")
subject_template = get_template(subject_template_name) subject_template = get_template(subject_template_name)
subject = subject_template.render(context=context) subject = subject_template.render(context=context)

View file

@ -59,7 +59,7 @@ from epplibwrapper import (
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
from waffle.decorators import flag_is_active, waffle_flag from waffle.decorators import waffle_flag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -102,13 +102,6 @@ class DomainBaseView(DomainPermissionView):
domain_pk = "domain:" + str(self.kwargs.get("pk")) domain_pk = "domain:" + str(self.kwargs.get("pk"))
self.session[domain_pk] = self.object self.session[domain_pk] = self.object
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainFormBaseView(DomainBaseView, FormMixin): class DomainFormBaseView(DomainBaseView, FormMixin):
""" """

View file

@ -228,10 +228,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
if request.path_info == self.NEW_URL_NAME: if request.path_info == self.NEW_URL_NAME:
# Clear context so the prop getter won't create a request here. # Clear context so the prop getter won't create a request here.
# Creating a request will be handled in the post method for the # Creating a request will be handled in the post method for the
# intro page. Only TEMPORARY context needed is has_profile_flag # intro page.
has_profile_flag = flag_is_active(self.request, "profile_feature") return render(request, "domain_request_intro.html", {})
context_stuff = {"has_profile_feature_flag": has_profile_flag}
return render(request, "domain_request_intro.html", context=context_stuff)
else: else:
return self.goto(self.steps.first) return self.goto(self.steps.first)
@ -380,7 +378,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def get_context_data(self): def get_context_data(self):
"""Define context for access on all wizard pages.""" """Define context for access on all wizard pages."""
has_profile_flag = flag_is_active(self.request, "profile_feature")
context_stuff = {} context_stuff = {}
if DomainRequest._form_complete(self.domain_request, self.request): if DomainRequest._form_complete(self.domain_request, self.request):
@ -397,8 +394,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"modal_description": "Once you submit this request, you wont be able to edit it until we review it.\ "modal_description": "Once you submit this request, you wont be able to edit it until we review it.\
Youll only be able to withdraw your request.", Youll only be able to withdraw your request.",
"review_form_is_complete": True, "review_form_is_complete": True,
# Use the profile waffle feature flag to toggle profile features throughout domain requests
"has_profile_feature_flag": has_profile_flag,
"user": self.request.user, "user": self.request.user,
} }
else: # form is not complete else: # form is not complete
@ -414,7 +409,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"modal_description": 'This request cannot be submitted yet.\ "modal_description": 'This request cannot be submitted yet.\
Return to the request and visit the steps that are marked as "incomplete."', Return to the request and visit the steps that are marked as "incomplete."',
"review_form_is_complete": False, "review_form_is_complete": False,
"has_profile_feature_flag": has_profile_flag,
"user": self.request.user, "user": self.request.user,
} }
return context_stuff return context_stuff
@ -740,13 +734,6 @@ class Finished(DomainRequestWizard):
class DomainRequestStatus(DomainRequestPermissionView): class DomainRequestStatus(DomainRequestPermissionView):
template_name = "domain_request_status.html" template_name = "domain_request_status.html"
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView): class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
"""This page will ask user to confirm if they want to withdraw """This page will ask user to confirm if they want to withdraw
@ -757,13 +744,6 @@ class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
template_name = "domain_request_withdraw_confirmation.html" template_name = "domain_request_withdraw_confirmation.html"
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView): class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView):
# this view renders no template # this view renders no template

View file

@ -1,3 +1,4 @@
import logging
from django.http import JsonResponse from django.http import JsonResponse
from django.core.paginator import Paginator from django.core.paginator import Paginator
from registrar.models import UserDomainRole, Domain from registrar.models import UserDomainRole, Domain
@ -5,89 +6,29 @@ from django.contrib.auth.decorators import login_required
from django.urls import reverse from django.urls import reverse
from django.db.models import Q from django.db.models import Q
logger = logging.getLogger(__name__)
@login_required @login_required
def get_domains_json(request): def get_domains_json(request):
"""Given the current request, """Given the current request,
get all domains that are associated with the UserDomainRole object""" get all domains that are associated with the UserDomainRole object"""
user_domain_roles = UserDomainRole.objects.filter(user=request.user) user_domain_roles = UserDomainRole.objects.filter(user=request.user).select_related("domain_info__sub_organization")
domain_ids = user_domain_roles.values_list("domain_id", flat=True) domain_ids = user_domain_roles.values_list("domain_id", flat=True)
objects = Domain.objects.filter(id__in=domain_ids) objects = Domain.objects.filter(id__in=domain_ids)
unfiltered_total = objects.count() unfiltered_total = objects.count()
# Handle sorting objects = apply_search(objects, request)
sort_by = request.GET.get("sort_by", "id") # Default to 'id' objects = apply_state_filter(objects, request)
order = request.GET.get("order", "asc") # Default to 'asc' objects = apply_sorting(objects, request)
# Handle search term
search_term = request.GET.get("search_term")
if search_term:
objects = objects.filter(Q(name__icontains=search_term))
# Handle state
status_param = request.GET.get("status")
if status_param:
status_list = status_param.split(",")
# if unknown is in status_list, append 'dns needed' since both
# unknown and dns needed display as DNS Needed, and both are
# searchable via state parameter of 'unknown'
if "unknown" in status_list:
status_list.append("dns needed")
# Split the status list into normal states and custom states
normal_states = [state for state in status_list if state in Domain.State.values]
custom_states = [state for state in status_list if state == "expired"]
# Construct Q objects for normal states that can be queried through ORM
state_query = Q()
if normal_states:
state_query |= Q(state__in=normal_states)
# Handle custom states in Python, as expired can not be queried through ORM
if "expired" in custom_states:
expired_domain_ids = [domain.id for domain in objects if domain.state_display() == "Expired"]
state_query |= Q(id__in=expired_domain_ids)
# Apply the combined query
objects = objects.filter(state_query)
# If there are filtered states, and expired is not one of them, domains with
# state_display of 'Expired' must be removed
if "expired" not in custom_states:
expired_domain_ids = [domain.id for domain in objects if domain.state_display() == "Expired"]
objects = objects.exclude(id__in=expired_domain_ids)
if sort_by == "state_display":
# Fetch the objects and sort them in Python
objects = list(objects) # Evaluate queryset to a list
objects.sort(key=lambda domain: domain.state_display(), reverse=(order == "desc"))
else:
if order == "desc":
sort_by = f"-{sort_by}"
objects = objects.order_by(sort_by)
paginator = Paginator(objects, 10) paginator = Paginator(objects, 10)
page_number = request.GET.get("page") page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
# Convert objects to JSON-serializable format domains = [serialize_domain(domain) for domain in page_obj.object_list]
domains = [
{
"id": domain.id,
"name": domain.name,
"expiration_date": domain.expiration_date,
"state": domain.state,
"state_display": domain.state_display(),
"get_state_help_text": domain.get_state_help_text(),
"action_url": reverse("domain", kwargs={"pk": domain.id}),
"action_label": ("View" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "Manage"),
"svg_icon": ("visibility" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "settings"),
}
for domain in page_obj.object_list
]
return JsonResponse( return JsonResponse(
{ {
@ -100,3 +41,80 @@ def get_domains_json(request):
"unfiltered_total": unfiltered_total, "unfiltered_total": unfiltered_total,
} }
) )
def apply_search(queryset, request):
search_term = request.GET.get("search_term")
if search_term:
queryset = queryset.filter(Q(name__icontains=search_term))
return queryset
def apply_state_filter(queryset, request):
status_param = request.GET.get("status")
if status_param:
status_list = status_param.split(",")
# if unknown is in status_list, append 'dns needed' since both
# unknown and dns needed display as DNS Needed, and both are
# searchable via state parameter of 'unknown'
if "unknown" in status_list:
status_list.append("dns needed")
# Split the status list into normal states and custom states
normal_states = [state for state in status_list if state in Domain.State.values]
custom_states = [state for state in status_list if state == "expired"]
# Construct Q objects for normal states that can be queried through ORM
state_query = Q()
if normal_states:
state_query |= Q(state__in=normal_states)
# Handle custom states in Python, as expired can not be queried through ORM
if "expired" in custom_states:
expired_domain_ids = [domain.id for domain in queryset if domain.state_display() == "Expired"]
state_query |= Q(id__in=expired_domain_ids)
# Apply the combined query
queryset = queryset.filter(state_query)
# If there are filtered states, and expired is not one of them, domains with
# state_display of 'Expired' must be removed
if "expired" not in custom_states:
expired_domain_ids = [domain.id for domain in queryset if domain.state_display() == "Expired"]
queryset = queryset.exclude(id__in=expired_domain_ids)
return queryset
def apply_sorting(queryset, request):
sort_by = request.GET.get("sort_by", "id")
order = request.GET.get("order", "asc")
if sort_by == "state_display":
objects = list(queryset)
objects.sort(key=lambda domain: domain.state_display(), reverse=(order == "desc"))
return objects
else:
if order == "desc":
sort_by = f"-{sort_by}"
return queryset.order_by(sort_by)
def serialize_domain(domain):
suborganization_name = None
try:
domain_info = domain.domain_info
if domain_info:
suborganization = domain_info.sub_organization
if suborganization:
suborganization_name = suborganization.name
except Domain.domain_info.RelatedObjectDoesNotExist:
domain_info = None
logger.debug(f"Issue in domains_json: We could not find domain_info for {domain}")
return {
"id": domain.id,
"name": domain.name,
"expiration_date": domain.expiration_date,
"state": domain.state,
"state_display": domain.state_display(),
"get_state_help_text": domain.get_state_help_text(),
"action_url": reverse("domain", kwargs={"pk": domain.id}),
"action_label": ("View" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "Manage"),
"svg_icon": ("visibility" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "settings"),
"suborganization": suborganization_name,
}

View file

@ -1,5 +1,4 @@
from django.shortcuts import render from django.shortcuts import render
from waffle.decorators import flag_is_active
def index(request): def index(request):
@ -7,10 +6,6 @@ def index(request):
context = {} context = {}
if request.user.is_authenticated: if request.user.is_authenticated:
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
# This controls the creation of a new domain request in the wizard # This controls the creation of a new domain request in the wizard
request.session["new_request"] = True request.session["new_request"] = True

View file

@ -11,7 +11,7 @@ from django.urls import NoReverseMatch, reverse
from registrar.models.user import User from registrar.models.user import User
from registrar.models.utility.generic_helper import replace_url_queryparams from registrar.models.utility.generic_helper import replace_url_queryparams
from registrar.views.utility.permission_views import UserProfilePermissionView from registrar.views.utility.permission_views import UserProfilePermissionView
from waffle.decorators import flag_is_active, waffle_flag from waffle.decorators import waffle_flag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -51,10 +51,8 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Extend get_context_data to include has_profile_feature_flag""" """Extend get_context_data"""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
# Set the profile_back_button_text based on the redirect parameter # Set the profile_back_button_text based on the redirect parameter
if kwargs.get("redirect") == "domain-request:": if kwargs.get("redirect") == "domain-request:":
@ -134,7 +132,7 @@ class FinishProfileSetupView(UserProfileView):
base_view_name = "finish-user-profile-setup" base_view_name = "finish-user-profile-setup"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Extend get_context_data to include has_profile_feature_flag""" """Extend get_context_data"""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# Show back button conditional on user having finished setup # Show back button conditional on user having finished setup

View file

@ -14,14 +14,12 @@ Rather than dealing with that, we keep everything centralized in one location.
""" """
from django.shortcuts import render from django.shortcuts import render
from waffle.decorators import flag_is_active
def custom_500_error_view(request, context=None): def custom_500_error_view(request, context=None):
"""Used to redirect 500 errors to a custom view""" """Used to redirect 500 errors to a custom view"""
if context is None: if context is None:
context = {} context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "500.html", context=context, status=500) return render(request, "500.html", context=context, status=500)
@ -29,7 +27,6 @@ def custom_401_error_view(request, context=None):
"""Used to redirect 401 errors to a custom view""" """Used to redirect 401 errors to a custom view"""
if context is None: if context is None:
context = {} context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "401.html", context=context, status=401) return render(request, "401.html", context=context, status=401)
@ -37,5 +34,4 @@ def custom_403_error_view(request, exception=None, context=None):
"""Used to redirect 403 errors to a custom view""" """Used to redirect 403 errors to a custom view"""
if context is None: if context is None:
context = {} context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "403.html", context=context, status=403) return render(request, "403.html", context=context, status=403)