mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-23 19:20:47 +02:00
Merge branch 'main' into litterbox/2399-fill-senior-official
This commit is contained in:
commit
7e3f56e0f6
52 changed files with 747 additions and 255 deletions
49
.github/ISSUE_TEMPLATE/developer-onboarding.md
vendored
49
.github/ISSUE_TEMPLATE/developer-onboarding.md
vendored
|
@ -14,13 +14,29 @@ assignees: abroddrick
|
|||
|
||||
## Installation
|
||||
|
||||
There are several tools we use locally that you will need to have.
|
||||
- [ ] [Install the cf CLI v7](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html#pkg-mac) for the ability to deploy
|
||||
There are several tools we use locally that you will need to have.
|
||||
|
||||
- [ ] [Cloudfoundry CLI](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html#pkg-mac) Note: If you are on Windows the cli will be under `cf8` or `cf7` depending on which version you install.
|
||||
- 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)
|
||||
- [ ] 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/)
|
||||
- [ ] [GPG](https://gnupg.org/download/)
|
||||
- Make sure you have `gpg` >2.1.7. Run `gpg --version` to check. If not, [install gnupg](https://formulae.brew.sh/formula/gnupg)
|
||||
- This may not work on DHS devices. Alternatively, you can [use ssh keys](#setting-up-commit-signing-with-ssh) instead.
|
||||
- [ ] Docker Community Edition*
|
||||
- [ ] Git*
|
||||
- [ ] VSCode (our preferred editor)*
|
||||
- [ ] Github Desktop* or the Github CLI*
|
||||
|
||||
The following tools are optional but recommended. For DHS devices, these can be requested through the DHS IT portal:
|
||||
- [ ] Slack Desktop App**
|
||||
- [ ] Python 3.10*
|
||||
- [ ] NodeJS (latest version available)*
|
||||
- [ ] Putty*
|
||||
- [ ] Windows Subsystem for Linux*
|
||||
|
||||
* Must be requested through DHS IT portal on DHS devices
|
||||
|
||||
** Downloadable via DHS Software Center
|
||||
|
||||
## Access
|
||||
|
||||
|
@ -37,7 +53,12 @@ cf login -a api.fr.cloud.gov --sso
|
|||
|
||||
**Note:** As mentioned in the [Login documentation](https://developers.login.gov/testing/), the sandbox Login account is different account from your regular, production Login account. If you have not created a Login account for the sandbox before, you will need to create a new account first.
|
||||
|
||||
- [ ] Optional- add yourself as a codeowner if desired. See the [Developer readme](https://github.com/cisagov/getgov/blob/main/docs/developer/README.md) for how to do this and what it does.
|
||||
Follow the [.gov onboarding dev setup instructions](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.94jwfwkpkhdx). Confirm you successfully set up the following accounts:
|
||||
- [ ] Identity sandbox accounts - 1 superuser access account and 1 analyst access account.
|
||||
- [ ] Login.gov account to access stable
|
||||
|
||||
**Optional**
|
||||
- [ ] Add yourself as a codeowner if desired. See the [Developer readme](https://github.com/cisagov/getgov/blob/main/docs/developer/README.md) for how to do this and what it does.
|
||||
|
||||
### Steps for the onboarder
|
||||
- [ ] Add the onboardee to cloud.gov org (cisa-dotgov)
|
||||
|
@ -124,3 +145,19 @@ Additionally, consider a gpg key manager like Kleopatra if you run into issues w
|
|||
We have three types of environments: stable, staging, and sandbox. Stable (production)and staging (pre-prod) get deployed via tagged release, and developer sandboxes are given to get.gov developers to mess around in a production-like environment without disrupting stable or staging. Each sandbox is namespaced and will automatically be deployed too when the appropriate branch syntax is used for that space in an open pull request. There are several things you need to setup to make the sandbox work for a developer.
|
||||
|
||||
All automation for setting up a developer sandbox is documented in the scripts for [creating a developer sandbox](../../ops/scripts/create_dev_sandbox.sh) and [removing a developer sandbox](../../ops/scripts/destroy_dev_sandbox.sh). A Cloud.gov organization administrator will have to perform the script in order to create the sandbox.
|
||||
|
||||
## Known Issues
|
||||
|
||||
### SSL Verification Failure
|
||||
Some developers using Government Furnished Equipment (GFE) have problems using tools such as git and pip due to SSL verification failurse. This happens because GFE has a custom certificate chain installed, but these tools use their own certificate bundles. As a result, when they try to verify an ssl connection, they cannot and so the connection fails. To resolve this in pip you can use --use-feature=truststore to direct pip to use the local certificate store. If you are running into this issue when using git on windows, run ```git config --global http.sslbackend schannel```.
|
||||
|
||||
If you are running into these issues in a docker container you will need to export the root certificate and pull it into the container. Ask another developer how to do this properly.
|
||||
|
||||
### Puppeteer Download Error
|
||||
When building the node image either individually or with docker compose, there may be an error caused by a node package call puppeteer. This can be resolved by adding `ENV PUPPETEER_SKIP_DOWNLOAD=true` to [node.Dockerfile](../../src/node.Dockerfile) after the COPY command.
|
||||
|
||||
### Checksum Error
|
||||
There is an unresolved issue with python package installation that occurs after the above SSL Verification failure has been resolved. It often manifests as a checksum error, where the hash of a download .whl file (python package) does not match the expected value. This appears to be because pythonhosted.org is cutting off download connections to some devices for some packages (the behavior is somewhat inconsistent). We have outstanding issues with PyPA and DHS IT to fix this. In the meantime we have a [workaround](#developing-using-docker).
|
||||
|
||||
## Developing Using Docker
|
||||
While we have unresolved issues with certain devices, you can pull a pre-built docker image from matthewswspence/getgov-base that comes with all the needed packages installed. To do this, you will need to change the very first line in the main [Dockerfile](../../src/Dockerfile) to `FROM matthewswspence/getgov-base:latest`. Note: this change will need to be reverted before any branch can be merged. Additionally, this will only resolve the [checksum error](#checksum-error), you will still need to resolve any other issues through the listed instructions. We are actively working to resolve this inconvenience.
|
||||
|
|
|
@ -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'
|
||||
|
|
1
.github/workflows/deploy-sandbox.yaml
vendored
1
.github/workflows/deploy-sandbox.yaml
vendored
|
@ -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"
|
||||
|
|
1
.github/workflows/migrate.yaml
vendored
1
.github/workflows/migrate.yaml
vendored
|
@ -16,6 +16,7 @@ on:
|
|||
- stable
|
||||
- staging
|
||||
- development
|
||||
- ad
|
||||
- ms
|
||||
- ag
|
||||
- litterbox
|
||||
|
|
1
.github/workflows/reset-db.yaml
vendored
1
.github/workflows/reset-db.yaml
vendored
|
@ -16,6 +16,7 @@ on:
|
|||
options:
|
||||
- staging
|
||||
- development
|
||||
- ad
|
||||
- ms
|
||||
- ag
|
||||
- litterbox
|
||||
|
|
32
ops/manifests/manifest-ad.yaml
Normal file
32
ops/manifests/manifest-ad.yaml
Normal 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
9
src/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -1169,6 +1169,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const statusIndicator = document.querySelector('.domain__filter-indicator');
|
||||
const statusToggle = document.querySelector('.usa-button--filter');
|
||||
const noPortfolioFlag = document.getElementById('no-portfolio-js-flag');
|
||||
const portfolioElement = document.getElementById('portfolio-js-value');
|
||||
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
|
||||
|
||||
/**
|
||||
* Loads rows in the domains list, as well as updates pagination around the domains list
|
||||
|
@ -1178,10 +1180,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
* @param {*} order - the sort order {asc, desc}
|
||||
* @param {*} scroll - control for the scrollToElement functionality
|
||||
* @param {*} searchTerm - the search term
|
||||
* @param {*} portfolio - the portfolio id
|
||||
*/
|
||||
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, status = currentStatus, searchTerm = currentSearchTerm) {
|
||||
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, status = currentStatus, searchTerm = currentSearchTerm, portfolio = portfolioValue) {
|
||||
// fetch json of page of domains, given params
|
||||
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&status=${status}&search_term=${searchTerm}`)
|
||||
let url = `/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&status=${status}&search_term=${searchTerm}`
|
||||
if (portfolio)
|
||||
url += `&portfolio=${portfolio}`
|
||||
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -213,4 +213,4 @@ a.usa-button--unstyled:visited {
|
|||
|
||||
.margin-right-neg-4px {
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
|
@ -15,3 +15,4 @@
|
|||
margin-right: units(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,4 +27,4 @@
|
|||
|
||||
/*--------------------------------------------------
|
||||
--- Admin ---------------------------------*/
|
||||
@forward "admin";
|
||||
@forward "admin";
|
|
@ -241,7 +241,6 @@ TEMPLATES = [
|
|||
"registrar.context_processors.is_demo_site",
|
||||
"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",
|
||||
|
@ -665,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",
|
||||
|
|
|
@ -26,7 +26,6 @@ from registrar.views.domain_request import Step
|
|||
from registrar.views.domain_requests_json import get_domain_requests_json
|
||||
from registrar.views.domains_json import get_domains_json
|
||||
from registrar.views.utility import always_404
|
||||
from registrar.views.portfolios import PortfolioDomainsView, PortfolioDomainRequestsView, PortfolioOrganizationView
|
||||
from api.views import available, get_current_federal, get_current_full
|
||||
|
||||
|
||||
|
@ -61,19 +60,19 @@ for step, view in [
|
|||
urlpatterns = [
|
||||
path("", views.index, name="home"),
|
||||
path(
|
||||
"portfolio/<int:portfolio_id>/domains/",
|
||||
PortfolioDomainsView.as_view(),
|
||||
name="portfolio-domains",
|
||||
"domains/",
|
||||
views.PortfolioDomainsView.as_view(),
|
||||
name="domains",
|
||||
),
|
||||
path(
|
||||
"portfolio/<int:portfolio_id>/domain_requests/",
|
||||
PortfolioDomainRequestsView.as_view(),
|
||||
name="portfolio-domain-requests",
|
||||
"requests/",
|
||||
views.PortfolioDomainRequestsView.as_view(),
|
||||
name="domain-requests",
|
||||
),
|
||||
path(
|
||||
"portfolio/<int:portfolio_id>/organization/",
|
||||
PortfolioOrganizationView.as_view(),
|
||||
name="portfolio-organization",
|
||||
"organization/",
|
||||
views.PortfolioOrganizationView.as_view(),
|
||||
name="organization",
|
||||
),
|
||||
path(
|
||||
"admin/logout/",
|
||||
|
|
|
@ -50,10 +50,6 @@ def org_user_status(request):
|
|||
}
|
||||
|
||||
|
||||
def add_portfolio_to_context(request):
|
||||
return {"portfolio": getattr(request, "portfolio", None)}
|
||||
|
||||
|
||||
def add_path_to_context(request):
|
||||
return {"path": getattr(request, "path", None)}
|
||||
|
||||
|
@ -70,11 +66,15 @@ def portfolio_permissions(request):
|
|||
"has_base_portfolio_permission": False,
|
||||
"has_domains_portfolio_permission": False,
|
||||
"has_domain_requests_portfolio_permission": False,
|
||||
"portfolio": None,
|
||||
"has_organization_feature_flag": False,
|
||||
}
|
||||
return {
|
||||
"has_base_portfolio_permission": request.user.has_base_portfolio_permission(),
|
||||
"has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(),
|
||||
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(),
|
||||
"portfolio": request.user.portfolio,
|
||||
"has_organization_feature_flag": flag_is_active(request, "organization_feature"),
|
||||
}
|
||||
except AttributeError:
|
||||
# Handles cases where request.user might not exist
|
||||
|
@ -82,4 +82,6 @@ def portfolio_permissions(request):
|
|||
"has_base_portfolio_permission": False,
|
||||
"has_domains_portfolio_permission": False,
|
||||
"has_domain_requests_portfolio_permission": False,
|
||||
"portfolio": None,
|
||||
"has_organization_feature_flag": False,
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -56,14 +56,27 @@ class Command(BaseCommand):
|
|||
self.clean_table(table_name)
|
||||
|
||||
def clean_table(self, table_name):
|
||||
"""Delete all rows in the given table"""
|
||||
"""Delete all rows in the given table.
|
||||
|
||||
Delete in batches to be able to handle large tables"""
|
||||
try:
|
||||
# Get the model class dynamically
|
||||
model = apps.get_model("registrar", table_name)
|
||||
# Use a transaction to ensure database integrity
|
||||
with transaction.atomic():
|
||||
model.objects.all().delete()
|
||||
logger.info(f"Successfully cleaned table {table_name}")
|
||||
BATCH_SIZE = 1000
|
||||
total_deleted = 0
|
||||
|
||||
# Get initial batch of primary keys
|
||||
pks = list(model.objects.values_list("pk", flat=True)[:BATCH_SIZE])
|
||||
|
||||
while pks:
|
||||
# Use a transaction to ensure database integrity
|
||||
with transaction.atomic():
|
||||
deleted, _ = model.objects.filter(pk__in=pks).delete()
|
||||
total_deleted += deleted
|
||||
logger.debug(f"Deleted {deleted} {table_name}s, total deleted: {total_deleted}")
|
||||
# Get the next batch of primary keys
|
||||
pks = list(model.objects.values_list("pk", flat=True)[:BATCH_SIZE])
|
||||
logger.info(f"Successfully cleaned table {table_name}, deleted {total_deleted} rows")
|
||||
except LookupError:
|
||||
logger.error(f"Model for table {table_name} not found.")
|
||||
except Exception as e:
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
# Generated by Django 4.2.10 on 2024-07-25 12:45
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0113_user_portfolio_user_portfolio_additional_permissions_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="portfolio_additional_permissions",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("view_all_domains", "View all domains and domain reports"),
|
||||
("view_managed_domains", "View managed domains"),
|
||||
("view_member", "View members"),
|
||||
("edit_member", "Create and edit members"),
|
||||
("view_all_requests", "View all requests"),
|
||||
("view_created_requests", "View created requests"),
|
||||
("edit_requests", "Create and edit requests"),
|
||||
("view_portfolio", "View organization"),
|
||||
("edit_portfolio", "Edit organization"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
blank=True,
|
||||
help_text="Select one or more additional permissions.",
|
||||
null=True,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -76,11 +76,6 @@ class User(AbstractUser):
|
|||
|
||||
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
|
||||
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
|
||||
# EDIT_DOMAINS is really self.domains. We add is hear and leverage it in has_permission
|
||||
# so we have one way to test for portfolio and domain edit permissions
|
||||
# Do we need to check for portfolio domains specifically?
|
||||
# NOTE: A user on an org can currently invite a user outside the org
|
||||
EDIT_DOMAINS = "edit_domains", "User is a manager on a domain"
|
||||
|
||||
VIEW_MEMBER = "view_member", "View members"
|
||||
EDIT_MEMBER = "edit_member", "Create and edit members"
|
||||
|
@ -268,11 +263,6 @@ class User(AbstractUser):
|
|||
def _has_portfolio_permission(self, portfolio_permission):
|
||||
"""The views should only call this function when testing for perms and not rely on roles."""
|
||||
|
||||
# EDIT_DOMAINS === user is a manager on a domain (has UserDomainRole)
|
||||
# NOTE: Should we check whether the domain is in the portfolio?
|
||||
if portfolio_permission == self.UserPortfolioPermissionChoices.EDIT_DOMAINS and self.domains.exists():
|
||||
return True
|
||||
|
||||
if not self.portfolio:
|
||||
return False
|
||||
|
||||
|
@ -286,21 +276,14 @@ class User(AbstractUser):
|
|||
return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
||||
|
||||
def has_domains_portfolio_permission(self):
|
||||
return (
|
||||
self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
|
||||
or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
|
||||
# or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_DOMAINS)
|
||||
)
|
||||
|
||||
def has_edit_domains_portfolio_permission(self):
|
||||
return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_DOMAINS)
|
||||
return self._has_portfolio_permission(
|
||||
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
|
||||
) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
|
||||
|
||||
def has_domain_requests_portfolio_permission(self):
|
||||
return (
|
||||
self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
|
||||
or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
|
||||
# or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
||||
)
|
||||
return self._has_portfolio_permission(
|
||||
User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
|
||||
) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
|
||||
|
||||
@classmethod
|
||||
def needs_identity_verification(cls, email, uuid):
|
||||
|
|
|
@ -149,10 +149,10 @@ class CheckPortfolioMiddleware:
|
|||
request.portfolio = portfolio
|
||||
|
||||
if request.user.has_domains_portfolio_permission():
|
||||
portfolio_redirect = reverse("portfolio-domains", kwargs={"portfolio_id": portfolio.id})
|
||||
portfolio_redirect = reverse("domains")
|
||||
else:
|
||||
# View organization is the lowest access
|
||||
portfolio_redirect = reverse("portfolio-organization", kwargs={"portfolio_id": portfolio.id})
|
||||
portfolio_redirect = reverse("organization")
|
||||
|
||||
return HttpResponseRedirect(portfolio_redirect)
|
||||
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -40,39 +40,50 @@
|
|||
|
||||
{% include "includes/domain_dates.html" %}
|
||||
|
||||
{% if is_portfolio_user and not is_domain_manager %}
|
||||
<div class="usa-alert usa-alert--info usa-alert--slim">
|
||||
<div class="usa-alert__body">
|
||||
<p class="usa-alert__text ">
|
||||
To manage information for this domain, you must add yourself as a domain manager.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% url 'domain-dns-nameservers' pk=domain.id as url %}
|
||||
{% if domain.nameservers|length > 0 %}
|
||||
{% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=domain.is_editable %}
|
||||
{% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=is_editable %}
|
||||
{% else %}
|
||||
{% if domain.is_editable %}
|
||||
{% if is_editable %}
|
||||
<h2 class="margin-top-3"> DNS name servers </h2>
|
||||
<p> No DNS name servers have been added yet. Before your domain can be used we’ll need information about your domain name servers.</p>
|
||||
<a class="usa-button margin-bottom-1" href="{{url}}"> Add DNS name servers </a>
|
||||
{% else %}
|
||||
{% include "includes/summary_item.html" with title='DNS name servers' domains='true' value='' edit_link=url editable=domain.is_editable %}
|
||||
{% include "includes/summary_item.html" with title='DNS name servers' domains='true' value='' edit_link=url editable=is_editable %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% url 'domain-org-name-address' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=domain.is_editable %}
|
||||
{% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %}
|
||||
|
||||
{% url 'domain-senior-official' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=domain.is_editable %}
|
||||
{% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=is_editable %}
|
||||
|
||||
{# Conditionally display profile #}
|
||||
{% if not has_profile_feature_flag %}
|
||||
{% url 'domain-your-contact-information' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Your contact information' value=request.user contact='true' edit_link=url editable=domain.is_editable %}
|
||||
{% include "includes/summary_item.html" with title='Your contact information' value=request.user contact='true' edit_link=url editable=is_editable %}
|
||||
{% endif %}
|
||||
|
||||
{% url 'domain-security-email' pk=domain.id as url %}
|
||||
{% if security_email is not None and security_email not in hidden_security_emails%}
|
||||
{% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=domain.is_editable %}
|
||||
{% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=is_editable %}
|
||||
{% else %}
|
||||
{% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=domain.is_editable %}
|
||||
{% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=is_editable %}
|
||||
{% endif %}
|
||||
{% url 'domain-users' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Domain managers' users='true' list=True value=domain.permissions.all edit_link=url editable=domain.is_editable %}
|
||||
{% include "includes/summary_item.html" with title='Domain managers' users='true' list=True value=domain.permissions.all edit_link=url editable=is_editable %}
|
||||
|
||||
</div>
|
||||
{% endblock %} {# domain_content #}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
</a>
|
||||
</li>
|
||||
|
||||
{% if domain.is_editable %}
|
||||
{% if is_editable %}
|
||||
<li class="usa-sidenav__item">
|
||||
{% url 'domain-dns' pk=domain.id as url %}
|
||||
<a href="{{ url }}" {% if request.path|startswith:url %}class="usa-current"{% endif %}>
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
<section class="section--outlined domain-requests" id="domain-requests">
|
||||
<div class="grid-row">
|
||||
<!-- Use portfolio_base_permission when merging into 2366 and then delete this comment -->
|
||||
{% if portfolio is None %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
{% load static %}
|
||||
|
||||
<section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
|
||||
<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 %}">
|
||||
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
|
||||
<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 %}
|
||||
<h2 id="domains-header" class="display-inline-block">Domains</h2>
|
||||
<span class="display-none" id="no-portfolio-js-flag"></span>
|
||||
<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 -->
|
||||
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||
{% endif %}
|
||||
<div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
|
||||
<section aria-label="Domains search component" class="margin-top-2">
|
||||
|
@ -45,7 +47,6 @@
|
|||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
|
||||
{% if portfolio %}
|
||||
<div class="display-flex flex-align-center">
|
||||
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
|
||||
|
@ -149,7 +150,6 @@
|
|||
<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="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 %}
|
||||
|
|
|
@ -53,6 +53,10 @@
|
|||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %}
|
||||
{% input_with_errors form.title %}
|
||||
{% endwith %}
|
||||
|
||||
{% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %}
|
||||
{% with show_readonly=True add_class="display-none" group_classes="usa-form-editable usa-form-editable padding-top-2 bold-usa-label" %}
|
||||
{% with link_href=login_help_url %}
|
||||
|
@ -64,10 +68,6 @@
|
|||
{% endwith %}
|
||||
{% endwith %}
|
||||
|
||||
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %}
|
||||
{% input_with_errors form.title %}
|
||||
{% endwith %}
|
||||
|
||||
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %}
|
||||
{% with add_class="usa-input--medium" %}
|
||||
{% input_with_errors form.phone %}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{% load static %}
|
||||
{% load custom_filters %}
|
||||
|
||||
<header class="usa-header usa-header--extended">
|
||||
<div class="usa-navbar">
|
||||
|
@ -14,8 +15,8 @@
|
|||
<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 %}">
|
||||
{% url 'domains' as url %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domains
|
||||
</a>
|
||||
</li>
|
||||
|
@ -27,8 +28,8 @@
|
|||
</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 %}">
|
||||
{% url 'domain-requests' as url %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domain requests
|
||||
</a>
|
||||
</li>
|
||||
|
@ -39,7 +40,7 @@
|
|||
</a>
|
||||
</li>
|
||||
<li class="usa-nav__primary-item">
|
||||
{% url 'portfolio-organization' portfolio.id as url %}
|
||||
{% url 'organization' as url %}
|
||||
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
|
||||
<a href="{{ url }}" class="usa-nav-link padding-y-0">
|
||||
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<nav aria-label="Domain sections">
|
||||
<ul class="usa-sidenav">
|
||||
<li class="usa-sidenav__item">
|
||||
{% url 'portfolio-organization' portfolio_id=portfolio.id as url %}
|
||||
{% url 'organization' as url %}
|
||||
<a href="{{ url }}"
|
||||
{% if request.path == url %}class="usa-current"{% endif %}
|
||||
>
|
||||
|
|
|
@ -145,3 +145,8 @@ def format_phone(value):
|
|||
phone_number = PhoneNumber.from_string(value)
|
||||
return phone_number.as_national
|
||||
return value
|
||||
|
||||
|
||||
@register.filter
|
||||
def in_path(url, path):
|
||||
return url in path
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -811,36 +811,69 @@ class TestCleanTables(TestCase):
|
|||
@override_settings(IS_PRODUCTION=False)
|
||||
def test_command_cleans_tables(self):
|
||||
"""test that the handle method functions properly to clean tables"""
|
||||
with less_console_noise():
|
||||
with patch("django.apps.apps.get_model") as get_model_mock:
|
||||
model_mock = MagicMock()
|
||||
get_model_mock.return_value = model_mock
|
||||
|
||||
with patch(
|
||||
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
|
||||
return_value=True,
|
||||
):
|
||||
call_command("clean_tables")
|
||||
with patch("django.apps.apps.get_model") as get_model_mock:
|
||||
model_mock = MagicMock()
|
||||
get_model_mock.return_value = model_mock
|
||||
|
||||
table_names = [
|
||||
"DomainInformation",
|
||||
"DomainRequest",
|
||||
"PublicContact",
|
||||
"Domain",
|
||||
"User",
|
||||
"Contact",
|
||||
"Website",
|
||||
"DraftDomain",
|
||||
"HostIp",
|
||||
"Host",
|
||||
]
|
||||
with patch(
|
||||
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
|
||||
return_value=True,
|
||||
):
|
||||
|
||||
# List of pks to be returned in batches, one list for each of 11 tables
|
||||
pk_batch = [1, 2, 3, 4, 5, 6]
|
||||
# Create a list of batches with alternating non-empty and empty lists
|
||||
pk_batches = [pk_batch, []] * 11
|
||||
|
||||
# Set the side effect of values_list to return different pk batches
|
||||
# First time values_list is called it returns list of 6 objects to delete;
|
||||
# Next time values_list is called it returns empty list
|
||||
def values_list_side_effect(*args, **kwargs):
|
||||
if args == ("pk",) and kwargs.get("flat", False):
|
||||
return pk_batches.pop(0)
|
||||
return []
|
||||
|
||||
model_mock.objects.values_list.side_effect = values_list_side_effect
|
||||
# Mock the return value of `delete()` to be (6, ...)
|
||||
model_mock.objects.filter.return_value.delete.return_value = (6, None)
|
||||
|
||||
call_command("clean_tables")
|
||||
|
||||
table_names = [
|
||||
"DomainInformation",
|
||||
"DomainRequest",
|
||||
"FederalAgency",
|
||||
"PublicContact",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"Domain",
|
||||
"User",
|
||||
"Contact",
|
||||
"Website",
|
||||
"DraftDomain",
|
||||
]
|
||||
|
||||
expected_filter_calls = [call(pk__in=[1, 2, 3, 4, 5, 6]) for _ in range(11)]
|
||||
|
||||
actual_filter_calls = [c for c in model_mock.objects.filter.call_args_list if "pk__in" in c[1]]
|
||||
|
||||
try:
|
||||
# Assert that filter(pk__in=...) was called with expected arguments
|
||||
self.assertEqual(actual_filter_calls, expected_filter_calls)
|
||||
|
||||
# Check that delete() was called for each batch
|
||||
for batch in [[1, 2, 3, 4, 5, 6]]:
|
||||
model_mock.objects.filter(pk__in=batch).delete.assert_called()
|
||||
|
||||
# Check that each model's delete method was called
|
||||
for table_name in table_names:
|
||||
get_model_mock.assert_any_call("registrar", table_name)
|
||||
model_mock.objects.all().delete.assert_called()
|
||||
|
||||
self.logger_mock.info.assert_any_call("Successfully cleaned table DomainInformation")
|
||||
self.logger_mock.info.assert_any_call(
|
||||
f"Successfully cleaned table {table_name}, deleted 6 rows"
|
||||
)
|
||||
except AssertionError as e:
|
||||
print(f"AssertionError: {e}")
|
||||
raise
|
||||
|
||||
@override_settings(IS_PRODUCTION=False)
|
||||
def test_command_handles_nonexistent_model(self):
|
||||
|
@ -871,15 +904,33 @@ class TestCleanTables(TestCase):
|
|||
with patch("django.apps.apps.get_model") as get_model_mock:
|
||||
model_mock = MagicMock()
|
||||
get_model_mock.return_value = model_mock
|
||||
model_mock.objects.all().delete.side_effect = Exception("Some error")
|
||||
|
||||
# Mock the values_list so that DomainInformation attempts a delete
|
||||
pk_batches = [[1, 2, 3, 4, 5, 6], []]
|
||||
|
||||
def values_list_side_effect(*args, **kwargs):
|
||||
if args == ("pk",) and kwargs.get("flat", False):
|
||||
return pk_batches.pop(0)
|
||||
return []
|
||||
|
||||
model_mock.objects.values_list.side_effect = values_list_side_effect
|
||||
|
||||
# Mock delete to raise a generic exception
|
||||
model_mock.objects.filter.return_value.delete.side_effect = Exception("Mocked delete exception")
|
||||
|
||||
with patch(
|
||||
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
|
||||
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit",
|
||||
return_value=True,
|
||||
):
|
||||
call_command("clean_tables")
|
||||
with self.assertRaises(Exception) as context:
|
||||
# Execute the command
|
||||
call_command("clean_tables")
|
||||
|
||||
self.logger_mock.error.assert_any_call("Error cleaning table DomainInformation: Some error")
|
||||
# Check the exception message
|
||||
self.assertEqual(str(context.exception), "Custom delete error")
|
||||
|
||||
# Assert that delete was called
|
||||
model_mock.objects.filter.return_value.delete.assert_called()
|
||||
|
||||
|
||||
class TestExportTables(MockEppLib):
|
||||
|
|
|
@ -1292,7 +1292,6 @@ class TestUser(TestCase):
|
|||
1. Returns False when a user does not have a portfolio
|
||||
2. Returns True when user has direct permission
|
||||
3. Returns True when user has permission through a role
|
||||
4. Returns True EDIT_DOMAINS when user does not have the perm but has UserDomainRole
|
||||
|
||||
Note: This tests _get_portfolio_permissions as a side effect
|
||||
"""
|
||||
|
@ -1304,11 +1303,9 @@ class TestUser(TestCase):
|
|||
|
||||
user_can_view_all_domains = self.user.has_domains_portfolio_permission()
|
||||
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission()
|
||||
user_can_edit_domains = self.user.has_edit_domains_portfolio_permission()
|
||||
|
||||
self.assertFalse(user_can_view_all_domains)
|
||||
self.assertFalse(user_can_view_all_requests)
|
||||
self.assertFalse(user_can_edit_domains)
|
||||
|
||||
self.user.portfolio = portfolio
|
||||
self.user.save()
|
||||
|
@ -1316,11 +1313,9 @@ class TestUser(TestCase):
|
|||
|
||||
user_can_view_all_domains = self.user.has_domains_portfolio_permission()
|
||||
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission()
|
||||
user_can_edit_domains = self.user.has_edit_domains_portfolio_permission()
|
||||
|
||||
self.assertTrue(user_can_view_all_domains)
|
||||
self.assertFalse(user_can_view_all_requests)
|
||||
self.assertFalse(user_can_edit_domains)
|
||||
|
||||
self.user.portfolio_roles = [User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
self.user.save()
|
||||
|
@ -1328,11 +1323,9 @@ class TestUser(TestCase):
|
|||
|
||||
user_can_view_all_domains = self.user.has_domains_portfolio_permission()
|
||||
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission()
|
||||
user_can_edit_domains = self.user.has_edit_domains_portfolio_permission()
|
||||
|
||||
self.assertTrue(user_can_view_all_domains)
|
||||
self.assertTrue(user_can_view_all_requests)
|
||||
self.assertFalse(user_can_edit_domains)
|
||||
|
||||
UserDomainRole.objects.all().get_or_create(
|
||||
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
|
||||
|
@ -1340,11 +1333,9 @@ class TestUser(TestCase):
|
|||
|
||||
user_can_view_all_domains = self.user.has_domains_portfolio_permission()
|
||||
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission()
|
||||
user_can_edit_domains = self.user.has_edit_domains_portfolio_permission()
|
||||
|
||||
self.assertTrue(user_can_view_all_domains)
|
||||
self.assertTrue(user_can_view_all_requests)
|
||||
self.assertTrue(user_can_edit_domains)
|
||||
|
||||
Portfolio.objects.all().delete()
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ from django.urls import reverse
|
|||
from django.contrib.auth import get_user_model
|
||||
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from .common import MockEppLib, MockSESClient, create_user # type: ignore
|
||||
from django_webtest import WebTest # type: ignore
|
||||
import boto3_mocking # type: ignore
|
||||
|
@ -138,6 +139,7 @@ class TestWithDomainPermissions(TestWithUser):
|
|||
Host.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
except ValueError: # pass if already deleted
|
||||
pass
|
||||
super().tearDown()
|
||||
|
@ -310,6 +312,33 @@ class TestDomainDetail(TestDomainOverview):
|
|||
self.assertContains(detail_page, "noinformation.gov")
|
||||
self.assertContains(detail_page, "Domain missing domain information")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_readonly_on_detail_page(self):
|
||||
"""Test that a domain, which is part of a portfolio, but for which the user is not a domain manager,
|
||||
properly displays read only"""
|
||||
|
||||
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
|
||||
# need to create a different user than self.user because the user needs permission assignments
|
||||
user = get_user_model().objects.create(
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
email="bogus@example.gov",
|
||||
phone="8003111234",
|
||||
title="test title",
|
||||
portfolio=portfolio,
|
||||
portfolio_roles=[User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov")
|
||||
DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio)
|
||||
self.client.force_login(user)
|
||||
detail_page = self.client.get(f"/domain/{domain.id}")
|
||||
# Check that alert message displays properly
|
||||
self.assertContains(
|
||||
detail_page, "To manage information for this domain, you must add yourself as a domain manager."
|
||||
)
|
||||
# Check that user does not have option to Edit domain
|
||||
self.assertNotContains(detail_page, "Edit")
|
||||
|
||||
|
||||
class TestDomainManagers(TestDomainOverview):
|
||||
def tearDown(self):
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from registrar.models import UserDomainRole, Domain
|
||||
from registrar.models import UserDomainRole, Domain, DomainInformation, Portfolio
|
||||
from django.urls import reverse
|
||||
from .test_views import TestWithUser
|
||||
from django_webtest import WebTest # type: ignore
|
||||
|
@ -15,15 +15,25 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
|
|||
self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="unknown")
|
||||
self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="dns needed")
|
||||
self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready")
|
||||
self.domain4 = Domain.objects.create(name="example4.com", expiration_date="2024-03-01", state="ready")
|
||||
|
||||
# Create UserDomainRoles
|
||||
UserDomainRole.objects.create(user=self.user, domain=self.domain1)
|
||||
UserDomainRole.objects.create(user=self.user, domain=self.domain2)
|
||||
UserDomainRole.objects.create(user=self.user, domain=self.domain3)
|
||||
|
||||
# Create Portfolio
|
||||
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Example org")
|
||||
|
||||
# Add domain3 and domain4 to portfolio
|
||||
DomainInformation.objects.create(creator=self.user, domain=self.domain3, portfolio=self.portfolio)
|
||||
DomainInformation.objects.create(creator=self.user, domain=self.domain4, portfolio=self.portfolio)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
UserDomainRole.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
super().tearDown()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_domains_json_unauthenticated(self):
|
||||
|
@ -104,6 +114,83 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
|
|||
)
|
||||
self.assertEqual(svg_icon_expected, svg_icons[i])
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_domains_json_with_portfolio(self):
|
||||
"""Test that an authenticated user gets the list of 2 domains for portfolio."""
|
||||
|
||||
response = self.app.get(reverse("get_domains_json"), {"portfolio": self.portfolio.id})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
||||
# Check pagination info
|
||||
self.assertEqual(data["page"], 1)
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertFalse(data["has_previous"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 2)
|
||||
|
||||
# Expected domains
|
||||
expected_domains = [self.domain3, self.domain4]
|
||||
|
||||
# Extract fields from response
|
||||
domain_ids = [domain["id"] for domain in data["domains"]]
|
||||
names = [domain["name"] for domain in data["domains"]]
|
||||
expiration_dates = [domain["expiration_date"] for domain in data["domains"]]
|
||||
states = [domain["state"] for domain in data["domains"]]
|
||||
state_displays = [domain["state_display"] for domain in data["domains"]]
|
||||
get_state_help_texts = [domain["get_state_help_text"] for domain in data["domains"]]
|
||||
action_urls = [domain["action_url"] for domain in data["domains"]]
|
||||
action_labels = [domain["action_label"] for domain in data["domains"]]
|
||||
svg_icons = [domain["svg_icon"] for domain in data["domains"]]
|
||||
|
||||
# Check fields for each domain
|
||||
for i, expected_domain in enumerate(expected_domains):
|
||||
self.assertEqual(expected_domain.id, domain_ids[i])
|
||||
self.assertEqual(expected_domain.name, names[i])
|
||||
self.assertEqual(expected_domain.expiration_date, expiration_dates[i])
|
||||
self.assertEqual(expected_domain.state, states[i])
|
||||
|
||||
# Parsing the expiration date from string to date
|
||||
parsed_expiration_date = parse_date(expiration_dates[i])
|
||||
expected_domain.expiration_date = parsed_expiration_date
|
||||
|
||||
# Check state_display and get_state_help_text
|
||||
self.assertEqual(expected_domain.state_display(), state_displays[i])
|
||||
self.assertEqual(expected_domain.get_state_help_text(), get_state_help_texts[i])
|
||||
|
||||
self.assertEqual(reverse("domain", kwargs={"pk": expected_domain.id}), action_urls[i])
|
||||
|
||||
# Check action_label
|
||||
user_domain_role_exists = UserDomainRole.objects.filter(
|
||||
domain_id=expected_domains[i].id, user=self.user
|
||||
).exists()
|
||||
action_label_expected = (
|
||||
"View"
|
||||
if not user_domain_role_exists
|
||||
or expected_domains[i].state
|
||||
in [
|
||||
Domain.State.DELETED,
|
||||
Domain.State.ON_HOLD,
|
||||
]
|
||||
else "Manage"
|
||||
)
|
||||
self.assertEqual(action_label_expected, action_labels[i])
|
||||
|
||||
# Check svg_icon
|
||||
svg_icon_expected = (
|
||||
"visibility"
|
||||
if not user_domain_role_exists
|
||||
or expected_domains[i].state
|
||||
in [
|
||||
Domain.State.DELETED,
|
||||
Domain.State.ON_HOLD,
|
||||
]
|
||||
else "settings"
|
||||
)
|
||||
self.assertEqual(svg_icon_expected, svg_icons[i])
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_domains_json_search(self):
|
||||
"""Test search."""
|
||||
|
|
|
@ -111,9 +111,7 @@ class TestPortfolio(WebTest):
|
|||
with override_flag("organization_feature", active=True):
|
||||
# This will redirect the user to the portfolio page.
|
||||
# Follow implicity checks if our redirect is working.
|
||||
response = self.app.get(
|
||||
reverse("portfolio-domains", kwargs={"portfolio_id": self.portfolio.pk}), status=403
|
||||
)
|
||||
response = self.app.get(reverse("domains"), status=403)
|
||||
# Assert the response is a 403 Forbidden
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
@ -127,9 +125,7 @@ class TestPortfolio(WebTest):
|
|||
with override_flag("organization_feature", active=True):
|
||||
# This will redirect the user to the portfolio page.
|
||||
# Follow implicity checks if our redirect is working.
|
||||
response = self.app.get(
|
||||
reverse("portfolio-domain-requests", kwargs={"portfolio_id": self.portfolio.pk}), status=403
|
||||
)
|
||||
response = self.app.get(reverse("domain-requests"), status=403)
|
||||
# Assert the response is a 403 Forbidden
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
@ -143,9 +139,7 @@ class TestPortfolio(WebTest):
|
|||
with override_flag("organization_feature", active=True):
|
||||
# This will redirect the user to the portfolio page.
|
||||
# Follow implicity checks if our redirect is working.
|
||||
response = self.app.get(
|
||||
reverse("portfolio-organization", kwargs={"portfolio_id": self.portfolio.pk}), status=403
|
||||
)
|
||||
response = self.app.get(reverse("organization"), status=403)
|
||||
# Assert the response is a 403 Forbidden
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
@ -169,12 +163,8 @@ class TestPortfolio(WebTest):
|
|||
self.assertContains(portfolio_page, self.portfolio.organization_name)
|
||||
self.assertNotContains(portfolio_page, "<h1>Organization</h1>")
|
||||
self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
|
||||
self.assertContains(
|
||||
portfolio_page, reverse("portfolio-domains", kwargs={"portfolio_id": self.portfolio.pk})
|
||||
)
|
||||
self.assertContains(
|
||||
portfolio_page, reverse("portfolio-domain-requests", kwargs={"portfolio_id": self.portfolio.pk})
|
||||
)
|
||||
self.assertContains(portfolio_page, reverse("domains"))
|
||||
self.assertContains(portfolio_page, reverse("domain-requests"))
|
||||
|
||||
# reducing portfolio permissions to just VIEW_PORTFOLIO, which should remove domains
|
||||
# and domain requests from nav
|
||||
|
@ -187,12 +177,8 @@ class TestPortfolio(WebTest):
|
|||
self.assertContains(portfolio_page, self.portfolio.organization_name)
|
||||
self.assertContains(portfolio_page, "<h1>Organization</h1>")
|
||||
self.assertNotContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
|
||||
self.assertNotContains(
|
||||
portfolio_page, reverse("portfolio-domains", kwargs={"portfolio_id": self.portfolio.pk})
|
||||
)
|
||||
self.assertNotContains(
|
||||
portfolio_page, reverse("portfolio-domain-requests", kwargs={"portfolio_id": self.portfolio.pk})
|
||||
)
|
||||
self.assertNotContains(portfolio_page, reverse("domains"))
|
||||
self.assertNotContains(portfolio_page, reverse("domain-requests"))
|
||||
|
||||
|
||||
class TestPortfolioOrganization(TestPortfolio):
|
||||
|
@ -209,7 +195,7 @@ class TestPortfolioOrganization(TestPortfolio):
|
|||
self.user.save()
|
||||
self.user.refresh_from_db()
|
||||
|
||||
page = self.app.get(reverse("portfolio-organization", kwargs={"portfolio_id": self.portfolio.pk}))
|
||||
page = self.app.get(reverse("organization"))
|
||||
self.assertContains(
|
||||
page, "The name of your federal agency will be publicly listed as the domain registrant."
|
||||
)
|
||||
|
@ -228,7 +214,7 @@ class TestPortfolioOrganization(TestPortfolio):
|
|||
|
||||
self.portfolio.organization_name = "Hotel California"
|
||||
self.portfolio.save()
|
||||
page = self.app.get(reverse("portfolio-organization", kwargs={"portfolio_id": self.portfolio.pk}))
|
||||
page = self.app.get(reverse("organization"))
|
||||
# Once in the sidenav, once in the main nav, once in the form
|
||||
self.assertContains(page, "Hotel California", count=3)
|
||||
|
||||
|
@ -246,9 +232,7 @@ class TestPortfolioOrganization(TestPortfolio):
|
|||
|
||||
self.portfolio.address_line1 = "1600 Penn Ave"
|
||||
self.portfolio.save()
|
||||
portfolio_org_name_page = self.app.get(
|
||||
reverse("portfolio-organization", kwargs={"portfolio_id": self.portfolio.pk})
|
||||
)
|
||||
portfolio_org_name_page = self.app.get(reverse("organization"))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
portfolio_org_name_page.form["address_line1"] = "6 Downing st"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -17,3 +17,4 @@ from .domain import (
|
|||
from .user_profile import UserProfileView, FinishProfileSetupView
|
||||
from .health import *
|
||||
from .index import *
|
||||
from .portfolios import *
|
||||
|
|
|
@ -170,6 +170,17 @@ class DomainView(DomainBaseView):
|
|||
context["security_email"] = security_email
|
||||
return context
|
||||
|
||||
def can_access_domain_via_portfolio(self, pk):
|
||||
"""Most views should not allow permission to portfolio users.
|
||||
If particular views allow permissions, they will need to override
|
||||
this function."""
|
||||
if self.request.user.has_domains_portfolio_permission():
|
||||
if Domain.objects.filter(id=pk).exists():
|
||||
domain = Domain.objects.get(id=pk)
|
||||
if domain.domain_info.portfolio == self.request.user.portfolio:
|
||||
return True
|
||||
return False
|
||||
|
||||
def in_editable_state(self, pk):
|
||||
"""Override in_editable_state from DomainPermission
|
||||
Allow detail page to be viewable"""
|
||||
|
|
|
@ -6,6 +6,8 @@ from django.contrib.auth.decorators import login_required
|
|||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
||||
from registrar.models.domain_information import DomainInformation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -14,10 +16,9 @@ def get_domains_json(request):
|
|||
"""Given the current request,
|
||||
get all domains that are associated with the UserDomainRole object"""
|
||||
|
||||
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 = get_domain_ids_from_request(request)
|
||||
|
||||
objects = Domain.objects.filter(id__in=domain_ids)
|
||||
objects = Domain.objects.filter(id__in=domain_ids).select_related("domain_info__sub_organization")
|
||||
unfiltered_total = objects.count()
|
||||
|
||||
objects = apply_search(objects, request)
|
||||
|
@ -28,7 +29,7 @@ def get_domains_json(request):
|
|||
page_number = request.GET.get("page")
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
domains = [serialize_domain(domain) for domain in page_obj.object_list]
|
||||
domains = [serialize_domain(domain, request.user) for domain in page_obj.object_list]
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
|
@ -43,6 +44,21 @@ def get_domains_json(request):
|
|||
)
|
||||
|
||||
|
||||
def get_domain_ids_from_request(request):
|
||||
"""Get domain ids from request.
|
||||
|
||||
If portfolio specified, return domain ids associated with portfolio.
|
||||
Otherwise, return domain ids associated with request.user.
|
||||
"""
|
||||
portfolio = request.GET.get("portfolio")
|
||||
if portfolio:
|
||||
domain_infos = DomainInformation.objects.filter(portfolio=portfolio)
|
||||
return domain_infos.values_list("domain_id", flat=True)
|
||||
else:
|
||||
user_domain_roles = UserDomainRole.objects.filter(user=request.user)
|
||||
return user_domain_roles.values_list("domain_id", flat=True)
|
||||
|
||||
|
||||
def apply_search(queryset, request):
|
||||
search_term = request.GET.get("search_term")
|
||||
if search_term:
|
||||
|
@ -94,7 +110,7 @@ def apply_sorting(queryset, request):
|
|||
return queryset.order_by(sort_by)
|
||||
|
||||
|
||||
def serialize_domain(domain):
|
||||
def serialize_domain(domain, user):
|
||||
suborganization_name = None
|
||||
try:
|
||||
domain_info = domain.domain_info
|
||||
|
@ -106,6 +122,9 @@ def serialize_domain(domain):
|
|||
domain_info = None
|
||||
logger.debug(f"Issue in domains_json: We could not find domain_info for {domain}")
|
||||
|
||||
# Check if there is a UserDomainRole for this domain and user
|
||||
user_domain_role_exists = UserDomainRole.objects.filter(domain_id=domain.id, user=user).exists()
|
||||
view_only = not user_domain_role_exists or domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD]
|
||||
return {
|
||||
"id": domain.id,
|
||||
"name": domain.name,
|
||||
|
@ -114,7 +133,7 @@ def serialize_domain(domain):
|
|||
"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"),
|
||||
"action_label": ("View" if view_only else "Manage"),
|
||||
"svg_icon": ("visibility" if view_only else "settings"),
|
||||
"suborganization": suborganization_name,
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import logging
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.http import Http404
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.contrib import messages
|
||||
from registrar.forms.portfolio import PortfolioOrgAddressForm
|
||||
|
@ -9,7 +10,6 @@ from registrar.views.utility.permission_views import (
|
|||
PortfolioDomainsPermissionView,
|
||||
PortfolioBasePermissionView,
|
||||
)
|
||||
from waffle.decorators import flag_is_active
|
||||
from django.views.generic import View
|
||||
from django.views.generic.edit import FormMixin
|
||||
|
||||
|
@ -21,33 +21,18 @@ class PortfolioDomainsView(PortfolioDomainsPermissionView, View):
|
|||
|
||||
template_name = "portfolio_domains.html"
|
||||
|
||||
def get(self, request, portfolio_id):
|
||||
context = {}
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
|
||||
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
|
||||
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
|
||||
context["portfolio"] = portfolio
|
||||
|
||||
return render(request, "portfolio_domains.html", context)
|
||||
def get(self, request):
|
||||
return render(request, "portfolio_domains.html")
|
||||
|
||||
|
||||
class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
|
||||
|
||||
template_name = "portfolio_requests.html"
|
||||
|
||||
def get(self, request, portfolio_id):
|
||||
context = {}
|
||||
|
||||
def get(self, request):
|
||||
if self.request.user.is_authenticated:
|
||||
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
|
||||
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
|
||||
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
|
||||
context["portfolio"] = portfolio
|
||||
request.session["new_request"] = True
|
||||
|
||||
return render(request, "portfolio_requests.html", context)
|
||||
return render(request, "portfolio_requests.html")
|
||||
|
||||
|
||||
class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
|
||||
|
@ -63,14 +48,14 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
|
|||
def get_context_data(self, **kwargs):
|
||||
"""Add additional context data to the template."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
# no need to add portfolio to request context here
|
||||
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
|
||||
context["has_organization_feature_flag"] = flag_is_active(self.request, "organization_feature")
|
||||
return context
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
"""Get the portfolio object based on the URL parameter."""
|
||||
return get_object_or_404(Portfolio, id=self.kwargs.get("portfolio_id"))
|
||||
"""Get the portfolio object based on the request user."""
|
||||
portfolio = self.request.user.portfolio
|
||||
if portfolio is None:
|
||||
raise Http404("No organization found for this user")
|
||||
return portfolio
|
||||
|
||||
def get_form_kwargs(self):
|
||||
"""Include the instance in the form kwargs."""
|
||||
|
@ -107,4 +92,4 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
|
|||
|
||||
def get_success_url(self):
|
||||
"""Redirect to the overview page for the portfolio."""
|
||||
return reverse("portfolio-organization", kwargs={"portfolio_id": self.object.pk})
|
||||
return reverse("organization")
|
||||
|
|
|
@ -184,11 +184,17 @@ class DomainPermission(PermissionsLoginMixin):
|
|||
|
||||
# user needs to have a role on the domain
|
||||
if not UserDomainRole.objects.filter(user=self.request.user, domain__id=pk).exists():
|
||||
return False
|
||||
return self.can_access_domain_via_portfolio(pk)
|
||||
|
||||
# if we need to check more about the nature of role, do it here.
|
||||
return True
|
||||
|
||||
def can_access_domain_via_portfolio(self, pk):
|
||||
"""Most views should not allow permission to portfolio users.
|
||||
If particular views allow access to the domain pages, they will need to override
|
||||
this function."""
|
||||
return False
|
||||
|
||||
def in_editable_state(self, pk):
|
||||
"""Is the domain in an editable state"""
|
||||
|
||||
|
|
|
@ -43,6 +43,9 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC):
|
|||
context["is_analyst_or_superuser"] = user.has_perm("registrar.analyst_access_permission") or user.has_perm(
|
||||
"registrar.full_access_permission"
|
||||
)
|
||||
context["is_domain_manager"] = UserDomainRole.objects.filter(user=user, domain=self.object).exists()
|
||||
context["is_portfolio_user"] = self.can_access_domain_via_portfolio(self.object.pk)
|
||||
context["is_editable"] = self.is_editable()
|
||||
# Stored in a variable for the linter
|
||||
action = "analyst_action"
|
||||
action_location = "analyst_action_location"
|
||||
|
@ -54,6 +57,22 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC):
|
|||
|
||||
return context
|
||||
|
||||
def is_editable(self):
|
||||
"""Returns whether domain is editable in the context of the view"""
|
||||
logger.info("checking if is_editable")
|
||||
domain_editable = self.object.is_editable()
|
||||
if not domain_editable:
|
||||
return False
|
||||
|
||||
# if user is domain manager or analyst or admin, return True
|
||||
if (
|
||||
self.can_access_other_user_domains(self.object.id)
|
||||
or UserDomainRole.objects.filter(user=self.request.user, domain=self.object).exists()
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# Abstract property enforces NotImplementedError on an attribute.
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue