Merge branch 'main' into dk/2379-portfolio-domain-readonly-permissions

This commit is contained in:
David Kennedy 2024-08-01 18:10:48 -04:00
commit 9a6f5fc408
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
26 changed files with 291 additions and 78 deletions

View file

@ -29,6 +29,7 @@ on:
- hotgov
- litterbox
- ms
- ad
# GitHub Actions has no "good" way yet to dynamically input branches
branch:
description: 'Branch to deploy'

View file

@ -29,6 +29,7 @@ jobs:
|| startsWith(github.head_ref, 'litterbox/')
|| startsWith(github.head_ref, 'ag/')
|| startsWith(github.head_ref, 'ms/')
|| startsWith(github.head_ref, 'ad/')
outputs:
environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest"

View file

@ -16,6 +16,7 @@ on:
- stable
- staging
- development
- ad
- ms
- ag
- litterbox

View file

@ -16,6 +16,7 @@ on:
options:
- staging
- development
- ad
- ms
- ag
- litterbox

View file

@ -0,0 +1,32 @@
---
applications:
- name: getgov-ad
buildpacks:
- python_buildpack
path: ../../src
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
command: ./run.sh
health-check-type: http
health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env:
# Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup
# Tell Django where to find its configuration
DJANGO_SETTINGS_MODULE: registrar.config.settings
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-ad.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
- route: getgov-ad.app.cloud.gov
services:
- getgov-credentials
- getgov-ad-database

9
src/package-lock.json generated
View file

@ -9,7 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@uswds/uswds": "^3.8.0",
"@uswds/uswds": "^3.8.1",
"pa11y-ci": "^3.0.1",
"sass": "^1.54.8"
},
@ -187,9 +187,10 @@
}
},
"node_modules/@uswds/uswds": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/@uswds/uswds/-/uswds-3.8.0.tgz",
"integrity": "sha512-rMwCXe/u4HGkfskvS1Iuabapi/EXku3ChaIFW7y/dUhc7R1TXQhbbfp8YXEjmXPF0yqJnv9T08WPgS0fQqWZ8w==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/@uswds/uswds/-/uswds-3.8.1.tgz",
"integrity": "sha512-bKG/B9mJF1v0yoqth48wQDzST5Xyu3OxxpePIPDyhKWS84oDrCehnu3Z88JhSjdIAJMl8dtjtH8YvdO9kZUpAg==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"classlist-polyfill": "1.2.0",
"object-assign": "4.1.1",

View file

@ -10,11 +10,11 @@
"author": "",
"license": "ISC",
"dependencies": {
"@uswds/uswds": "^3.8.0",
"@uswds/uswds": "^3.8.1",
"pa11y-ci": "^3.0.1",
"sass": "^1.54.8"
},
"devDependencies": {
"@uswds/compile": "^1.0.0-beta.3"
}
}
}

View file

@ -207,15 +207,11 @@ function addOrRemoveSessionBoolean(name, add){
})();
/** An IIFE for pages in DjangoAdmin that use a clipboard button
*/
(function (){
function copyInnerTextToClipboard(elem) {
let text = elem.innerText
navigator.clipboard.writeText(text)
}
function copyToClipboardAndChangeIcon(button) {
// Assuming the input is the previous sibling of the button
let input = button.previousElementSibling;
@ -224,7 +220,7 @@ function addOrRemoveSessionBoolean(name, add){
if (input) {
navigator.clipboard.writeText(input.value).then(function() {
// Change the icon to a checkmark on successful copy
let buttonIcon = button.querySelector('.usa-button__clipboard use');
let buttonIcon = button.querySelector('.copy-to-clipboard use');
if (buttonIcon) {
let currentHref = buttonIcon.getAttribute('xlink:href');
let baseHref = currentHref.split('#')[0];
@ -233,21 +229,17 @@ function addOrRemoveSessionBoolean(name, add){
buttonIcon.setAttribute('xlink:href', baseHref + '#check');
// Change the button text
nearestSpan = button.querySelector("span")
let nearestSpan = button.querySelector("span")
let original_text = nearestSpan.innerText
nearestSpan.innerText = "Copied to clipboard"
setTimeout(function() {
// Change back to the copy icon
buttonIcon.setAttribute('xlink:href', currentHref);
if (button.classList.contains('usa-button__small-text')) {
nearestSpan.innerText = "Copy email";
} else {
nearestSpan.innerText = "Copy";
}
nearestSpan.innerText = original_text;
}, 2000);
}
}).catch(function(error) {
console.error('Clipboard copy failed', error);
});
@ -255,7 +247,7 @@ function addOrRemoveSessionBoolean(name, add){
}
function handleClipboardButtons() {
clipboardButtons = document.querySelectorAll(".usa-button__clipboard")
clipboardButtons = document.querySelectorAll(".copy-to-clipboard")
clipboardButtons.forEach((button) => {
// Handle copying the text to your clipboard,
@ -278,20 +270,7 @@ function addOrRemoveSessionBoolean(name, add){
});
}
function handleClipboardLinks() {
let emailButtons = document.querySelectorAll(".usa-button__clipboard-link");
if (emailButtons){
emailButtons.forEach((button) => {
button.addEventListener("click", ()=>{
copyInnerTextToClipboard(button);
})
});
}
}
handleClipboardButtons();
handleClipboardLinks();
})();
@ -605,3 +584,169 @@ function initializeWidgetOnList(list, parentId) {
}
}
})();
/** An IIFE for copy summary button (appears in DomainRegistry models)
*/
(function (){
const copyButton = document.getElementById('id-copy-to-clipboard-summary');
if (copyButton) {
copyButton.addEventListener('click', function() {
/// Generate a rich HTML summary text and copy to clipboard
//------ Organization Type
const organizationTypeElement = document.getElementById('id_organization_type');
const organizationType = organizationTypeElement.options[organizationTypeElement.selectedIndex].text;
//------ Alternative Domains
const alternativeDomainsDiv = document.querySelector('.form-row.field-alternative_domains .readonly');
const alternativeDomainslinks = alternativeDomainsDiv.querySelectorAll('a');
const alternativeDomains = Array.from(alternativeDomainslinks).map(link => link.textContent);
//------ Existing Websites
const existingWebsitesDiv = document.querySelector('.form-row.field-current_websites .readonly');
const existingWebsiteslinks = existingWebsitesDiv.querySelectorAll('a');
const existingWebsites = Array.from(existingWebsiteslinks).map(link => link.textContent);
//------ Additional Contacts
// 1 - Create a hyperlinks map so we can display contact details and also link to the contact
const otherContactsDiv = document.querySelector('.form-row.field-other_contacts .readonly');
let otherContactLinks = [];
const nameToUrlMap = {};
if (otherContactsDiv) {
otherContactLinks = otherContactsDiv.querySelectorAll('a');
otherContactLinks.forEach(link => {
const name = link.textContent.trim();
const url = link.href;
nameToUrlMap[name] = url;
});
}
// 2 - Iterate through contact details and assemble html for summary
let otherContactsSummary = ""
const bulletList = document.createElement('ul');
// CASE 1 - Contacts are not in a table (this happens if there is only one or two other contacts)
const contacts = document.querySelectorAll('.field-other_contacts .dja-detail-list dd');
if (contacts) {
contacts.forEach(contact => {
// Check if the <dl> element is not empty
const name = contact.querySelector('a#contact_info_name')?.innerText;
const title = contact.querySelector('span#contact_info_title')?.innerText;
const email = contact.querySelector('span#contact_info_email')?.innerText;
const phone = contact.querySelector('span#contact_info_phone')?.innerText;
const url = nameToUrlMap[name] || '#';
// Format the contact information
const listItem = document.createElement('li');
listItem.innerHTML = `<a href="${url}">${name}</a>, ${title}, ${email}, ${phone}`;
bulletList.appendChild(listItem);
});
}
// CASE 2 - Contacts are in a table (this happens if there is more than 2 contacts)
const otherContactsTable = document.querySelector('.form-row.field-other_contacts table tbody');
if (otherContactsTable) {
const otherContactsRows = otherContactsTable.querySelectorAll('tr');
otherContactsRows.forEach(contactRow => {
// Extract the contact details
const name = contactRow.querySelector('th').textContent.trim();
const title = contactRow.querySelectorAll('td')[0].textContent.trim();
const email = contactRow.querySelectorAll('td')[1].textContent.trim();
const phone = contactRow.querySelectorAll('td')[2].textContent.trim();
const url = nameToUrlMap[name] || '#';
// Format the contact information
const listItem = document.createElement('li');
listItem.innerHTML = `<a href="${url}">${name}</a>, ${title}, ${email}, ${phone}`;
bulletList.appendChild(listItem);
});
}
otherContactsSummary += bulletList.outerHTML
//------ Requested Domains
const requestedDomainElement = document.getElementById('id_requested_domain');
const requestedDomain = requestedDomainElement.options[requestedDomainElement.selectedIndex].text;
//------ Submitter
// Function to extract text by ID and handle missing elements
function extractTextById(id, divElement) {
if (divElement) {
const element = divElement.querySelector(`#${id}`);
return element ? ", " + element.textContent.trim() : '';
}
return '';
}
// Extract the submitter name, title, email, and phone number
const submitterDiv = document.querySelector('.form-row.field-submitter');
const submitterNameElement = document.getElementById('id_submitter');
const submitterName = submitterNameElement.options[submitterNameElement.selectedIndex].text;
const submitterTitle = extractTextById('contact_info_title', submitterDiv);
const submitterEmail = extractTextById('contact_info_email', submitterDiv);
const submitterPhone = extractTextById('contact_info_phone', submitterDiv);
let submitterInfo = `${submitterName}${submitterTitle}${submitterEmail}${submitterPhone}`;
//------ Senior Official
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
const seniorOfficialElement = document.getElementById('id_senior_official');
const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text;
const seniorOfficialTitle = extractTextById('contact_info_title', seniorOfficialDiv);
const seniorOfficialEmail = extractTextById('contact_info_email', seniorOfficialDiv);
const seniorOfficialPhone = extractTextById('contact_info_phone', seniorOfficialDiv);
let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`;
const html_summary = `<strong>Recommendation:</strong></br>` +
`<strong>Organization Type:</strong> ${organizationType}</br>` +
`<strong>Requested Domain:</strong> ${requestedDomain}</br>` +
`<strong>Current Websites:</strong> ${existingWebsites.join(', ')}</br>` +
`<strong>Rationale:</strong></br>` +
`<strong>Alternative Domains:</strong> ${alternativeDomains.join(', ')}</br>` +
`<strong>Submitter:</strong> ${submitterInfo}</br>` +
`<strong>Senior Official:</strong> ${seniorOfficialInfo}</br>` +
`<strong>Other Employees:</strong> ${otherContactsSummary}</br>`;
//Replace </br> with \n, then strip out all remaining html tags (replace <...> with '')
const plain_summary = html_summary.replace(/<\/br>|<br>/g, '\n').replace(/<\/?[^>]+(>|$)/g, '');
// Create Blobs with the summary content
const html_blob = new Blob([html_summary], { type: 'text/html' });
const plain_blob = new Blob([plain_summary], { type: 'text/plain' });
// Create a ClipboardItem with the Blobs
const clipboardItem = new ClipboardItem({
'text/html': html_blob,
'text/plain': plain_blob
});
// Write the ClipboardItem to the clipboard
navigator.clipboard.write([clipboardItem]).then(() => {
// Change the icon to a checkmark on successful copy
let buttonIcon = copyButton.querySelector('use');
if (buttonIcon) {
let currentHref = buttonIcon.getAttribute('xlink:href');
let baseHref = currentHref.split('#')[0];
// Append the new icon reference
buttonIcon.setAttribute('xlink:href', baseHref + '#check');
// Change the button text
nearestSpan = copyButton.querySelector("span")
original_text = nearestSpan.innerText
nearestSpan.innerText = "Copied to clipboard"
setTimeout(function() {
// Change back to the copy icon
buttonIcon.setAttribute('xlink:href', currentHref);
nearestSpan.innerText = original_text
}, 2000);
}
console.log('Summary copied to clipboard successfully!');
}).catch(err => {
console.error('Failed to copy text: ', err);
});
});
}
})();

View file

@ -369,9 +369,6 @@ input.admin-confirm-button {
padding: 10px 8px;
line-height: normal;
}
.usa-icon {
top: 2px;
}
a.button:active, a.button:focus {
text-decoration: none;
}
@ -447,15 +444,12 @@ address.margin-top-neg-1__detail-list {
}
}
td button.usa-button__clipboard-link, address.dja-address-contact-list {
address.dja-address-contact-list {
font-size: unset;
}
address.dja-address-contact-list {
color: var(--body-quiet-color);
button.usa-button__clipboard-link {
font-size: unset;
}
}
// Mimic the normal label size
@ -464,11 +458,18 @@ address.dja-address-contact-list {
font-size: 0.875rem;
color: var(--body-quiet-color);
}
}
address button.usa-button__clipboard-link, td button.usa-button__clipboard-link {
font-size: 0.875rem !important;
}
// Targets the unstyled buttons in the form
.button--clipboard {
color: var(--link-fg);
}
// Targets the DJA buttom with a nested icon
button .usa-icon,
.button .usa-icon,
.button--clipboard .usa-icon {
vertical-align: middle;
}
.errors span.select2-selection {
@ -663,7 +664,7 @@ address.dja-address-contact-list {
align-items: center;
.usa-button__icon {
.usa-button--icon {
position: absolute;
right: auto;
left: 4px;
@ -681,10 +682,6 @@ address.dja-address-contact-list {
}
}
button.usa-button__clipboard {
color: var(--link-fg);
}
.no-outline-on-click:focus {
outline: none !important;
}

View file

@ -213,4 +213,4 @@ a.usa-button--unstyled:visited {
.margin-right-neg-4px {
margin-right: -4px;
}
}

View file

@ -15,3 +15,4 @@
margin-right: units(0.5);
}
}

View file

@ -27,4 +27,4 @@
/*--------------------------------------------------
--- Admin ---------------------------------*/
@forward "admin";
@forward "admin";

View file

@ -664,6 +664,7 @@ ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov",
"getgov-development.app.cloud.gov",
"getgov-ad.app.cloud.gov",
"getgov-ms.app.cloud.gov",
"getgov-ag.app.cloud.gov",
"getgov-litterbox.app.cloud.gov",

View file

@ -22,6 +22,11 @@ class UserFixture:
"""
ADMINS = [
{
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
"first_name": "Aditi",
"last_name": "Green",
},
{
"username": "be17c826-e200-4999-9389-2ded48c43691",
"first_name": "Matthew",
@ -120,6 +125,11 @@ class UserFixture:
]
STAFF = [
{
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
"first_name": "Aditi-Analyst",
"last_name": "Green-Analyst",
},
{
"username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
"first_name": "Matthew-Analyst",

View file

@ -215,6 +215,11 @@ class DomainRequest(TimeStampedModel):
}
return org_election_map
@classmethod
def get_org_label(cls, org_name: str):
# Translating the key that is given to the direct readable value
return cls(org_name).label if org_name else None
class OrganizationChoicesVerbose(models.TextChoices):
"""
Tertiary organization choices

View file

@ -1,4 +1,5 @@
{% extends "admin/change_form.html" %}
{% load static i18n %} <!-- Add this line to load static template tag -->
{% comment %} Replace the Django ul markup with a div. We'll edit the child markup accordingly in change_form_object_tools {% endcomment %}
{% block object-tools %}
@ -9,4 +10,4 @@
{% endblock %}
</div>
{% endif %}
{% endblock %}
{% endblock %}

View file

@ -1,4 +1,5 @@
{% load i18n admin_urls %}
{% load i18n static %}
{% comment %} Replace li with p for more semantic HTML if we have a single child {% endcomment %}
{% block object-tools-items %}
@ -13,8 +14,21 @@
</li>
</ul>
{% else %}
<p class="margin-0 padding-0">
<a href="{% add_preserved_filters history_url %}" class="historylink">{% translate "History" %}</a>
</p>
<ul>
<li>
<a href="{% add_preserved_filters history_url %}">{% translate "History" %}</a>
</li>
{% if opts.model_name == 'domainrequest' %}
<li>
<a id="id-copy-to-clipboard-summary" class="button--clipboard" type="button" href="#">
<svg class="usa-icon" >
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span>{% translate "Copy request summary" %}</span>
</a>
</li>
{% endif %}
</ul>
{% endif %}
{% endblock %}
{% endblock %}

View file

@ -8,7 +8,7 @@ Template for an input field with a clipboard
<div class="admin-icon-group">
{{ field }}
<button
class="usa-button usa-button--unstyled padding-left-1 usa-button__icon usa-button__clipboard"
class="usa-button usa-button--unstyled padding-left-1 usa-button--icon button--clipboard copy-to-clipboard"
type="button"
>
<div class="no-outline-on-click">
@ -25,7 +25,7 @@ Template for an input field with a clipboard
<div class="admin-icon-group admin-icon-group__clipboard-link">
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button__icon usa-button__clipboard text-no-underline"
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline"
type="button"
>
<svg

View file

@ -2,25 +2,28 @@
<address class="{% if no_title_top_padding %}margin-top-neg-1__detail-list{% endif %} {% if user.has_contact_info %}margin-bottom-1{% endif %} dja-address-contact-list">
{% if show_formatted_name %}
{% if user.get_formatted_name %}
<a href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a><br />
<a id="contact_info_name" href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a>
{% else %}
None<br />
None
{% endif %}
{% endif %}
</br>
{% if user.has_contact_info %}
{# Title #}
{% if user.title %}
{{ user.title }}
<br>
<span id="contact_info_title">{{ user.title }}</span>
{% else %}
None<br>
None
{% endif %}
</br>
{# Email #}
{% if user.email %}
{{ user.email }}
<span id="contact_info_email">{{ user.email }}</span>
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
<br class="admin-icon-group__br">
{% else %}
@ -29,7 +32,7 @@
{# Phone #}
{% if user.phone %}
{{ user.phone }}
<span id="contact_info_phone">{{ user.phone }}</span>
<br>
{% else %}
None<br>
@ -40,6 +43,6 @@
{% endif %}
{% if user_verification_type %}
{{ user_verification_type }}
<span id="contact_info_phone">{{ user_verification_type }}</span>
{% endif %}
</address>

View file

@ -5,8 +5,8 @@ accept and become a domain manager.
</p>
<p>
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent.
A “received” status indicates that the recipient has logged in.
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent. Deleting an invitation with an "invited" status will prevent the user from signing in.
A “received” status indicates that the recipient has logged in. Deleting an invitation with a "received" status will not revoke that user's access from the domain. To remove a user who has already signed in, go to <a class="text-underline" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User domain roles</a> and delete the role for the correct domain/manager combination.
</p>
<p>

View file

@ -219,7 +219,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<td class="padding-left-1 text-size-small">
<input aria-hidden="true" class="display-none" value="{{ contact.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button__icon usa-button__clipboard usa-button__small-text text-no-underline"
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard usa-button__small-text text-no-underline"
type="button"
>
<svg

View file

@ -1,11 +1,9 @@
{% load static %}
<section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
<div class="grid-row">
<div class="section--outlined__header margin-bottom-3 {% if portfolio is None %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domains-header" class="flex-6">Domains</h2>
</div>
<h2 id="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span>
{% else %}
<!-- Embedding the portfolio value in a data attribute -->

View file

@ -164,7 +164,7 @@ class TestDomainInvitationAdmin(TestCase):
)
# Assert that the filters are added
self.assertContains(response, "invited", count=4)
self.assertContains(response, "invited", count=5)
self.assertContains(response, "Invited", count=2)
self.assertContains(response, "retrieved", count=2)
self.assertContains(response, "Retrieved", count=2)
@ -584,7 +584,7 @@ class TestDomainInformationAdmin(TestCase):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "usa-button__clipboard", count=4)
self.assertContains(response, "button--clipboard", count=4)
# cleanup this test
domain_info.delete()

View file

@ -444,7 +444,7 @@ class TestDomainAdminWithClient(TestCase):
self.assertContains(response, "(555) 555 5557")
# Test for the copy link
self.assertContains(response, "usa-button__clipboard")
self.assertContains(response, "button--clipboard")
# cleanup from this test
domain.delete()

View file

@ -1411,7 +1411,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "usa-button__clipboard", count=4)
self.assertContains(response, "button--clipboard", count=5)
# Test that Creator counts display properly
self.assertNotContains(response, "Approved domains")

View file

@ -374,8 +374,9 @@ class DomainExport(BaseExport):
if first_ready_on is None:
first_ready_on = "(blank)"
domain_org_type = model.get("generic_org_type")
human_readable_domain_org_type = DomainRequest.OrganizationChoices.get_org_label(domain_org_type)
# organization_type has generic_org_type AND is_election
domain_org_type = model.get("organization_type")
human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type)
domain_federal_type = model.get("federal_type")
human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type)
domain_type = human_readable_domain_org_type