Merge remote-tracking branch 'origin/main' into nl/2249-fix-db-reset-workflow

This commit is contained in:
CocoByte 2024-08-05 12:28:50 -06:00
commit 0c23aef0bb
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
64 changed files with 1086 additions and 296 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'
@ -73,20 +74,4 @@ jobs:
cf_org: cisa-dotgov
cf_space: ${{ env.ENVIRONMENT }}
cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml
comment:
runs-on: ubuntu-latest
needs: [deploy]
steps:
- uses: actions/github-script@v6
env:
ENVIRONMENT: ${{ github.event.inputs.environment }}
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.'
})

View file

@ -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

@ -45,6 +45,8 @@ When deploying to your personal sandbox, you should make sure all of the USWDS a
For ease of use, you can run the `deploy.sh <sandbox name>` script in the `/src` directory to build the assets and deploy to your sandbox. Similarly, you could run `build.sh <sandbox name>` script to just compile and collect the assets without deploying.
You may also manually deploy to a sandbox using our [manual deploy workflow](https://github.com/cisagov/manage.get.gov/actions/workflows/deploy-manual.yaml) on GitHub Actions. Select Run workflow and enter the branch you want to deploy to your sandbox of choice.
Your sandbox space should've been setup as part of the onboarding process. If this was not the case, please have an admin follow the instructions below.
## Creating a sandbox or new environment

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

View file

@ -116,6 +116,10 @@ sed -i '' '/ - development/ {a\
- '"$1"'
}' .github/workflows/migrate.yaml
sed -i '' '/ - backup/ {a\
- '"$1"'
}' .github/workflows/deploy-manual.yaml
sed -i '' '/${{startsWith(github.head_ref, / {a\
|| startsWith(github.head_ref, '"'$1'"')
}' .github/workflows/deploy-sandbox.yaml

View file

@ -49,6 +49,7 @@ rm ops/manifests/manifest-$1.yaml
sed -i '' "/getgov-$1.app.cloud.gov/d" src/registrar/config/settings.py
sed -i '' "/- $1/d" .github/workflows/reset-db.yaml
sed -i '' "/- $1/d" .github/workflows/migrate.yaml
sed -i '' "/- $1/d" .github/workflows/deploy-manual.yaml
echo "Cleaning up services, applications, and the Cloud.gov space for $1..."
cf delete getgov-$1

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,7 +10,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@uswds/uswds": "^3.8.0",
"@uswds/uswds": "^3.8.1",
"pa11y-ci": "^3.0.1",
"sass": "^1.54.8"
},

View file

@ -11,6 +11,7 @@ from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_group import DomainGroup
from registrar.models.suborganization import Suborganization
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@ -131,12 +132,12 @@ class MyUserAdminForm(UserChangeForm):
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
"portfolio_roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=User.UserPortfolioRoleChoices.choices
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions",
is_stacked=False,
choices=User.UserPortfolioPermissionChoices.choices,
choices=UserPortfolioPermissionChoices.choices,
),
}
@ -169,6 +170,24 @@ class MyUserAdminForm(UserChangeForm):
)
class PortfolioInvitationAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
class Meta:
model = models.PortfolioInvitation
fields = "__all__"
widgets = {
"portfolio_roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions",
is_stacked=False,
choices=UserPortfolioPermissionChoices.choices,
),
}
class DomainInformationAdminForm(forms.ModelForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
@ -1299,6 +1318,56 @@ class DomainInvitationAdmin(ListHeaderAdmin):
return super().changelist_view(request, extra_context=extra_context)
class PortfolioInvitationAdmin(ListHeaderAdmin):
"""Custom portfolio invitation admin class."""
form = PortfolioInvitationAdminForm
class Meta:
model = models.PortfolioInvitation
fields = "__all__"
_meta = Meta()
# Columns
list_display = [
"email",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
"status",
]
# Search
search_fields = [
"email",
"portfolio__name",
]
# Filters
list_filter = ("status",)
search_help_text = "Search by email or portfolio."
# Mark the FSM field 'status' as readonly
# to allow admin users to create Domain Invitations
# without triggering the FSM Transition Not Allowed
# error.
readonly_fields = ["status"]
autocomplete_fields = ["portfolio"]
change_form_template = "django/admin/email_clipboard_change_form.html"
# Select portfolio invitations to change -> Portfolio invitations
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Portfolio invitations"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class DomainInformationResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
import/export file"""
@ -2900,6 +2969,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin)
admin.site.register(models.DomainRequest, DomainRequestAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
admin.site.register(models.PortfolioInvitation, PortfolioInvitationAdmin)
admin.site.register(models.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin)

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

@ -82,3 +82,13 @@ legend.float-left-tablet + button.float-right-tablet {
color: var(--close-button-hover-bg);
}
}
.read-only-label {
font-size: size('body', 'sm');
color: color('primary');
margin-bottom: units(0.5);
}
.read-only-value {
margin-top: units(0);
}

View file

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

View file

@ -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",

View file

@ -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/",

View file

@ -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)}
@ -65,16 +61,20 @@ def add_has_profile_feature_flag_to_context(request):
def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context"""
try:
if not request.user or not request.user.is_authenticated:
if not request.user or not request.user.is_authenticated or not flag_is_active(request, "organization_feature"):
return {
"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": True,
}
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,
}

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

@ -0,0 +1,83 @@
# Generated by Django 4.2.10 on 2024-08-01 12:28
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import django_fsm
class Migration(migrations.Migration):
dependencies = [
("registrar", "0114_alter_user_portfolio_additional_permissions"),
]
operations = [
migrations.CreateModel(
name="PortfolioInvitation",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("email", models.EmailField(max_length=254)),
(
"portfolio_roles",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("organization_admin", "Admin"),
("organization_admin_read_only", "Admin read only"),
("organization_member", "Member"),
],
max_length=50,
),
blank=True,
help_text="Select one or more roles.",
null=True,
size=None,
),
),
(
"portfolio_additional_permissions",
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,
),
),
(
"status",
django_fsm.FSMField(
choices=[("invited", "Invited"), ("retrieved", "Retrieved")],
default="invited",
max_length=50,
protected=True,
),
),
(
"portfolio",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="portfolios", to="registrar.portfolio"
),
),
],
options={
"indexes": [models.Index(fields=["status"], name="registrar_p_status_aa4218_idx")],
},
),
]

View file

@ -1,4 +1,4 @@
from auditlog.registry import auditlog # type: ignore
from auditlog.registry import auditlog
from .contact import Contact
from .domain_request import DomainRequest
from .domain_information import DomainInformation
@ -16,6 +16,7 @@ from .website import Website
from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff
from .waffle_flag import WaffleFlag
from .portfolio_invitation import PortfolioInvitation
from .portfolio import Portfolio
from .domain_group import DomainGroup
from .suborganization import Suborganization
@ -40,6 +41,7 @@ __all__ = [
"TransitionDomain",
"VerifiedByStaff",
"WaffleFlag",
"PortfolioInvitation",
"Portfolio",
"DomainGroup",
"Suborganization",
@ -63,6 +65,7 @@ auditlog.register(Website)
auditlog.register(TransitionDomain)
auditlog.register(VerifiedByStaff)
auditlog.register(WaffleFlag)
auditlog.register(PortfolioInvitation)
auditlog.register(Portfolio)
auditlog.register(DomainGroup)
auditlog.register(Suborganization)

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

@ -0,0 +1,95 @@
"""People are invited by email to administer domains."""
import logging
from django.contrib.auth import get_user_model
from django.db import models
from django_fsm import FSMField, transition
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
from .utility.time_stamped_model import TimeStampedModel
from django.contrib.postgres.fields import ArrayField
logger = logging.getLogger(__name__)
class PortfolioInvitation(TimeStampedModel):
class Meta:
"""Contains meta information about this class"""
indexes = [
models.Index(fields=["status"]),
]
# Constants for status field
class PortfolioInvitationStatus(models.TextChoices):
INVITED = "invited", "Invited"
RETRIEVED = "retrieved", "Retrieved"
email = models.EmailField(
null=False,
blank=False,
)
portfolio = models.ForeignKey(
"registrar.Portfolio",
on_delete=models.CASCADE, # delete portfolio, then get rid of invitations
null=False,
related_name="portfolios",
)
portfolio_roles = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioRoleChoices.choices,
),
null=True,
blank=True,
help_text="Select one or more roles.",
)
portfolio_additional_permissions = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioPermissionChoices.choices,
),
null=True,
blank=True,
help_text="Select one or more additional permissions.",
)
status = FSMField(
choices=PortfolioInvitationStatus.choices,
default=PortfolioInvitationStatus.INVITED,
protected=True, # can't alter state except through transition methods!
)
def __str__(self):
return f"Invitation for {self.email} on {self.portfolio} is {self.status}"
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission.
Raises:
RuntimeError if no matching user can be found.
"""
# get a user with this email address
User = get_user_model()
try:
user = User.objects.get(email=self.email)
except User.DoesNotExist:
# should not happen because a matching user should exist before
# we retrieve this invitation
raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.")
# and create a role for that user on this portfolio
user.portfolio = self.portfolio
if self.portfolio_roles and len(self.portfolio_roles) > 0:
user.portfolio_roles = self.portfolio_roles
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
user.portfolio_additional_permissions = self.portfolio_additional_permissions
user.save()

View file

@ -5,8 +5,10 @@ from django.db import models
from django.db.models import Q
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .domain_invitation import DomainInvitation
from .portfolio_invitation import PortfolioInvitation
from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff
from .domain import Domain
@ -62,31 +64,6 @@ class User(AbstractUser):
# after they login.
FIXTURE_USER = "fixture_user", "Created by fixtures"
class UserPortfolioRoleChoices(models.TextChoices):
"""
Roles make it easier for admins to look at
"""
ORGANIZATION_ADMIN = "organization_admin", "Admin"
ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only"
ORGANIZATION_MEMBER = "organization_member", "Member"
class UserPortfolioPermissionChoices(models.TextChoices):
""" """
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
VIEW_MEMBER = "view_member", "View members"
EDIT_MEMBER = "edit_member", "Create and edit members"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
VIEW_PORTFOLIO = "view_portfolio", "View organization"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
PORTFOLIO_ROLE_PERMISSIONS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
@ -270,20 +247,23 @@ class User(AbstractUser):
return portfolio_permission in portfolio_permissions
# the methods below are checks for individual portfolio permissions. they are defined here
# the methods below are checks for individual portfolio permissions. They are defined here
# to make them easier to call elsewhere throughout the application
def has_base_portfolio_permission(self):
return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
def has_edit_org_portfolio_permission(self):
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_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)
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
) or self._has_portfolio_permission(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)
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
@classmethod
def needs_identity_verification(cls, email, uuid):
@ -392,6 +372,24 @@ class User(AbstractUser):
new_domain_invitation = DomainInvitation(email=transition_domain_email.lower(), domain=new_domain)
new_domain_invitation.save()
def check_portfolio_invitations_on_login(self):
"""When a user first arrives on the site, we need to retrieve any portfolio
invitations that match their email address."""
for invitation in PortfolioInvitation.objects.filter(
email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED
):
if self.portfolio is None:
try:
invitation.retrieve()
invitation.save()
except RuntimeError:
# retrieving should not fail because of a missing user, but
# if it does fail, log the error so a new user can continue
# logging in
logger.warn("Failed to retrieve invitation %s", invitation, exc_info=True)
else:
logger.warn("User already has a portfolio, did not retrieve invitation %s", invitation, exc_info=True)
def on_each_login(self):
"""Callback each time the user is authenticated.
@ -403,6 +401,7 @@ class User(AbstractUser):
"""
self.check_domain_invitations_on_login()
self.check_portfolio_invitations_on_login()
def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature")

View file

@ -0,0 +1,28 @@
from django.db import models
class UserPortfolioRoleChoices(models.TextChoices):
"""
Roles make it easier for admins to look at
"""
ORGANIZATION_ADMIN = "organization_admin", "Admin"
ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only"
ORGANIZATION_MEMBER = "organization_member", "Member"
class UserPortfolioPermissionChoices(models.TextChoices):
""" """
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
VIEW_MEMBER = "view_member", "View members"
EDIT_MEMBER = "edit_member", "Create and edit members"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
VIEW_PORTFOLIO = "view_portfolio", "View organization"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"

View file

@ -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)

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 %}

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 %}

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

@ -30,6 +30,8 @@
{% include "django/admin/includes/descriptions/verified_by_staff_description.html" %}
{% elif opts.model_name == 'website' %}
{% include "django/admin/includes/descriptions/website_description.html" %}
{% elif opts.model_name == 'portfolioinvitation' %}
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
{% else %}
<p>This table does not have a description yet.</p>
{% endif %}

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

@ -0,0 +1,11 @@
<p>
Portfolio invitations contain all individuals who have been invited to become members of an organization.
Invitations are sent via email, and the recipient must log in to the registrar to officially
accept and become a member.
</p>
<p>
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent
or that the recipient has logged in but is already a member of an organization.
A “received” status indicates that the recipient has logged in.
</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

@ -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 well 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 #}

View file

@ -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 %}>

View file

@ -2,7 +2,7 @@
<section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row">
{% if portfolio is None %}
{% if not has_domain_requests_portfolio_permission %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div>

View file

@ -1,17 +1,15 @@
{% load static %}
<section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
<div class="grid-row">
{% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domains-header" class="flex-6">Domains</h2>
</div>
<section class="section--outlined domains{% if not has_domains_portfolio_permission %} margin-top-0{% endif %}" id="domains">
<div class="section--outlined__header margin-bottom-3 {% if not has_domains_portfolio_permission %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not has_domains_portfolio_permission %}
<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 %}">
<div class="section--outlined__search {% if has_domains_portfolio_permission %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domains search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
@ -39,7 +37,7 @@
</form>
</section>
</div>
<div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if has_domains_portfolio_permission %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="mobile-lg:margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button">
<svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
@ -49,7 +47,7 @@
</section>
</div>
</div>
{% if portfolio %}
{% if has_domains_portfolio_permission %}
<div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2">
@ -152,7 +150,7 @@
<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>
{% if portfolio %}
{% if has_domains_portfolio_permission %}
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
{% endif %}
<th

View file

@ -35,7 +35,7 @@
<input type="hidden" name="redirect" value="{{ form.initial.redirect }}">
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
{% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
{% input_with_errors form.full_name %}
{% endwith %}
@ -53,12 +53,12 @@
{% endwith %}
</div>
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %}
{% with toggleable_input=True toggleable_label=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 toggleable_input=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 %}
{% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, youll need to update your Login.gov account and log back in. Get help with your Login.gov account." %}
{% with link_text="Get help with your Login.gov account" target_blank=True do_not_show_max_chars=True %}
@ -68,7 +68,7 @@
{% endwith %}
{% endwith %}
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %}
{% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable padding-top-2" %}
{% with add_class="usa-input--medium" %}
{% input_with_errors form.phone %}
{% endwith %}

View file

@ -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">

View file

@ -0,0 +1,7 @@
{% comment %}
Template include for read-only form fields
{% endcomment %}
<h4 class="read-only-label">{{ field.label }}</h4>
<p class="read-only-value">{{ field.value }}</p>

View file

@ -27,8 +27,8 @@ error messages, if necessary.
{% endif %}
{% if not field.widget_type == "checkbox" %}
{% if show_edit_button %}
{% include "includes/label_with_edit_button.html" with bold_label=True %}
{% if toggleable_label %}
{% include "includes/toggleable_label.html" with bold_label=True %}
{% else %}
{% include "django/forms/label.html" %}
{% endif %}
@ -63,8 +63,8 @@ error messages, if necessary.
<div class="display-flex flex-align-center">
{% endif %}
{% if show_readonly %}
{% include "includes/readonly_input.html" %}
{% if toggleable_input %}
{% include "includes/toggleable_input.html" %}
{% endif %}
{# this is the input field, itself #}

View file

@ -23,42 +23,53 @@
<p>The name of your federal agency will be publicly listed as the domain registrant.</p>
{% if has_edit_org_portfolio_permission %}
<p>
The federal agency for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% include "includes/form_errors.html" with form=form %}
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}
<p>
<strong class="text-primary display-block margin-bottom-1">Federal agency</strong>
{{ portfolio }}
<h4 class="read-only-label">Federal agency</h4>
<p class="read-only-value">
{{ portfolio.federal_agency }}
</p>
{% input_with_errors form.address_line1 %}
{% input_with_errors form.address_line2 %}
{% input_with_errors form.city %}
{% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" %}
{% input_with_errors form.zipcode %}
{% endwith %}
<button
type="submit"
class="usa-button"
>
<button type="submit" class="usa-button">
Save
</button>
</form>
{% else %}
<h4 class="read-only-label">Federal agency</h4>
<p class="read-only-value">
{{ portfolio.federal_agency }}
</p>
{% if form.address_line1.value is not None %}
{% include "includes/input_read_only.html" with field=form.address_line1 %}
{% endif %}
{% if form.address_line2.value is not None %}
{% include "includes/input_read_only.html" with field=form.address_line2 %}
{% endif %}
{% if form.city.value is not None %}
{% include "includes/input_read_only.html" with field=form.city %}
{% endif %}
{% if form.state_territory.value is not None %}
{% include "includes/input_read_only.html" with field=form.state_territory %}
{% endif %}
{% if form.zipcode.value is not None %}
{% include "includes/input_read_only.html" with field=form.zipcode %}
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -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 %}
>

View file

@ -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

View file

@ -26,7 +26,7 @@ def input_with_errors(context, field=None): # noqa: C901
add_group_class: append to input element's surrounding tag's `class` attribute
attr_* - adds or replaces any single html attribute for the input
add_error_attr_* - like `attr_*` but only if field.errors is not empty
show_edit_button: shows a simple edit button, and adds display-none to the input field.
toggleable_input: shows a simple edit button, and adds display-none to the input field.
Example usage:
```
@ -92,7 +92,7 @@ def input_with_errors(context, field=None): # noqa: C901
elif key == "add_group_class":
group_classes.append(value)
elif key == "show_edit_button":
elif key == "toggleable_input":
# Hide the primary input field.
# Used such that we can toggle it with JS
if "display-none" not in classes:

View file

@ -13,6 +13,7 @@ from registrar.admin import (
ContactAdmin,
DomainInformationAdmin,
MyHostAdmin,
PortfolioInvitationAdmin,
UserDomainRoleAdmin,
VerifiedByStaffAdmin,
FsmModelResource,
@ -38,6 +39,7 @@ from registrar.models import (
UserGroup,
TransitionDomain,
)
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.verified_by_staff import VerifiedByStaff
@ -163,6 +165,77 @@ class TestDomainInvitationAdmin(TestCase):
follow=True,
)
# Assert that the filters are added
self.assertContains(response, "invited", count=5)
self.assertContains(response, "Invited", count=2)
self.assertContains(response, "retrieved", count=2)
self.assertContains(response, "Retrieved", count=2)
# Check for the HTML context specificially
invited_html = '<a href="?status__exact=invited">Invited</a>'
retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>'
self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_html, count=1)
class TestPortfolioInvitationAdmin(TestCase):
"""Tests for the PortfolioInvitationAdmin class as super user
Notes:
all tests share superuser; do not change this model in tests
tests have available superuser, client, and admin
"""
@classmethod
def setUpClass(cls):
cls.factory = RequestFactory()
cls.admin = ListHeaderAdmin(model=PortfolioInvitationAdmin, admin_site=AdminSite())
cls.superuser = create_superuser()
def setUp(self):
"""Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080")
def tearDown(self):
"""Delete all DomainInvitation objects"""
PortfolioInvitation.objects.all().delete()
Contact.objects.all().delete()
@classmethod
def tearDownClass(self):
User.objects.all().delete()
@less_console_noise_decorator
def test_has_model_description(self):
"""Tests if this model has a model description on the table view"""
self.client.force_login(self.superuser)
response = self.client.get(
"/admin/registrar/portfolioinvitation/",
follow=True,
)
# Make sure that the page is loaded correctly
self.assertEqual(response.status_code, 200)
# Test for a description snippet
self.assertContains(
response,
"Portfolio invitations contain all individuals who have been invited to become members of an organization.",
)
self.assertContains(response, "Show more")
def test_get_filters(self):
"""Ensures that our filters are displaying correctly"""
with less_console_noise():
self.client.force_login(self.superuser)
response = self.client.get(
"/admin/registrar/portfolioinvitation/",
{},
follow=True,
)
# Assert that the filters are added
self.assertContains(response, "invited", count=4)
self.assertContains(response, "Invited", count=2)
@ -584,7 +657,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

@ -20,7 +20,9 @@ from registrar.models import (
import boto3_mocking
from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.transition_domain import TransitionDomain
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
from registrar.utility.constants import BranchChoices
@ -1071,8 +1073,8 @@ class TestDomainInformation(TestCase):
return {k: v for k, v in dict_obj.items() if k not in bad_fields}
class TestInvitations(TestCase):
"""Test the retrieval of invitations."""
class TestDomainInvitations(TestCase):
"""Test the retrieval of domain invitations."""
@less_console_noise_decorator
def setUp(self):
@ -1116,6 +1118,65 @@ class TestInvitations(TestCase):
self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain))
class TestPortfolioInvitations(TestCase):
"""Test the retrieval of portfolio invitations."""
@less_console_noise_decorator
def setUp(self):
self.email = "mayor@igorville.gov"
self.email2 = "creator@igorville.gov"
self.user, _ = User.objects.get_or_create(email=self.email)
self.user2, _ = User.objects.get_or_create(email=self.email2, username="creator")
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Hotel California")
self.portfolio_role_base = UserPortfolioRoleChoices.ORGANIZATION_MEMBER
self.portfolio_role_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN
self.portfolio_permission_1 = UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS
self.portfolio_permission_2 = UserPortfolioPermissionChoices.EDIT_REQUESTS
self.invitation, _ = PortfolioInvitation.objects.get_or_create(
email=self.email,
portfolio=self.portfolio,
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
)
def tearDown(self):
super().tearDown()
Portfolio.objects.all().delete()
PortfolioInvitation.objects.all().delete()
User.objects.all().delete()
@less_console_noise_decorator
def test_retrieval(self):
self.assertFalse(self.user.portfolio)
self.invitation.retrieve()
self.user.refresh_from_db()
self.assertEqual(self.user.portfolio.organization_name, "Hotel California")
self.assertEqual(self.user.portfolio_roles, [self.portfolio_role_base, self.portfolio_role_admin])
self.assertEqual(
self.user.portfolio_additional_permissions, [self.portfolio_permission_1, self.portfolio_permission_2]
)
self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
@less_console_noise_decorator
def test_retrieve_missing_user_error(self):
# get rid of matching users
User.objects.filter(email=self.email).delete()
with self.assertRaises(RuntimeError):
self.invitation.retrieve()
@less_console_noise_decorator
def test_retrieve_user_already_member_error(self):
self.assertFalse(self.user.portfolio)
portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Tokyo Hotel")
self.user.portfolio = portfolio2
self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel")
self.user.save()
self.user.check_portfolio_invitations_on_login()
self.user.refresh_from_db()
self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel")
self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
class TestUser(TestCase):
"""Test actions that occur on user login,
test class method that controls how users get validated."""
@ -1135,6 +1196,7 @@ class TestUser(TestCase):
DomainRequest.objects.all().delete()
DraftDomain.objects.all().delete()
TransitionDomain.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
UserDomainRole.objects.all().delete()
@ -1297,7 +1359,7 @@ class TestUser(TestCase):
"""
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
self.user.save()
self.user.refresh_from_db()
@ -1317,7 +1379,7 @@ class TestUser(TestCase):
self.assertTrue(user_can_view_all_domains)
self.assertFalse(user_can_view_all_requests)
self.user.portfolio_roles = [User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save()
self.user.refresh_from_db()

View file

@ -6,6 +6,8 @@ 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 registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from .common import MockEppLib, MockSESClient, create_user # type: ignore
from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore
@ -138,6 +140,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 +313,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=[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):

View file

@ -181,7 +181,8 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
# Check svg_icon
svg_icon_expected = (
"visibility"
if expected_domains[i].state
if not user_domain_role_exists
or expected_domains[i].state
in [
Domain.State.DELETED,
Domain.State.ON_HOLD,

View file

@ -10,6 +10,7 @@ from registrar.models import (
UserDomainRole,
User,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .common import create_test_user
from waffle.testutils import override_flag
@ -55,7 +56,7 @@ class TestPortfolio(WebTest):
def test_middleware_does_not_redirect_if_no_portfolio(self):
"""Test that user with no assigned portfolio is not redirected when attempting to access home"""
self.app.set_user(self.user.username)
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
@ -67,10 +68,10 @@ class TestPortfolio(WebTest):
@less_console_noise_decorator
def test_middleware_redirects_to_portfolio_organization_page(self):
"""Test that user with VIEW_PORTFOLIO is redirected to portfolio organization page"""
"""Test that user with a portfolio and VIEW_PORTFOLIO is redirected to portfolio organization page"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
@ -83,12 +84,13 @@ class TestPortfolio(WebTest):
@less_console_noise_decorator
def test_middleware_redirects_to_portfolio_domains_page(self):
"""Test that user with VIEW_PORTFOLIO and VIEW_ALL_DOMAINS is redirected to portfolio domains page"""
"""Test that user with a portfolio, VIEW_PORTFOLIO, VIEW_ALL_DOMAINS
is redirected to portfolio domains page"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
]
self.user.save()
self.user.refresh_from_db()
@ -111,9 +113,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 +127,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,21 +141,64 @@ 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)
@less_console_noise_decorator
def test_portfolio_organization_page_read_only(self):
"""Test that user with a portfolio can access the portfolio organization page, read only"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.portfolio.city = "Los Angeles"
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.portfolio.save()
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
response = self.app.get(reverse("organization"))
# Assert the response is a 200
self.assertEqual(response.status_code, 200)
# The label for Federal agency will always be a h4
self.assertContains(response, '<h4 class="read-only-label">Federal agency</h4>')
# The read only label for city will be a h4
self.assertContains(response, '<h4 class="read-only-label">City</h4>')
self.assertNotContains(response, 'for="id_city"')
self.assertContains(response, '<p class="read-only-value">Los Angeles</p>')
@less_console_noise_decorator
def test_portfolio_organization_page_edit_access(self):
"""Test that user with a portfolio can access the portfolio organization page, read only"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
]
self.portfolio.city = "Los Angeles"
self.portfolio.save()
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
response = self.app.get(reverse("organization"))
# Assert the response is a 200
self.assertEqual(response.status_code, 200)
# The label for Federal agency will always be a h4
self.assertContains(response, '<h4 class="read-only-label">Federal agency</h4>')
# The read only label for city will be a h4
self.assertNotContains(response, '<h4 class="read-only-label">City</h4>')
self.assertNotContains(response, '<p class="read-only-value">Los Angeles</p>>')
self.assertContains(response, 'for="id_city"')
@less_console_noise_decorator
def test_navigation_links_hidden_when_user_not_have_permission(self):
"""Test that navigation links are hidden when user does not have portfolio permissions"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
]
self.user.save()
self.user.refresh_from_db()
@ -169,16 +210,12 @@ 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
# removing non-basic portfolio perms, which should remove domains
# and domain requests from nav
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save()
self.user.refresh_from_db()
@ -187,68 +224,96 @@ 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"))
@less_console_noise_decorator
def test_navigation_links_hidden_when_user_not_have_role(self):
"""Test that admin / memmber roles are associated with the right access"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
portfolio_page = self.app.get(reverse("home")).follow()
# Assert that we're on the right page
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("domains"))
self.assertContains(portfolio_page, reverse("domain-requests"))
class TestPortfolioOrganization(TestPortfolio):
# removing non-basic portfolio role, which should remove domains
# and domain requests from nav
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
self.user.save()
self.user.refresh_from_db()
portfolio_page = self.app.get(reverse("home")).follow()
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("domains"))
self.assertNotContains(portfolio_page, reverse("domain-requests"))
@less_console_noise_decorator
def test_portfolio_org_name(self):
"""Can load portfolio's org name page."""
with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
]
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."
)
@less_console_noise_decorator
def test_domain_org_name_address_content(self):
"""Org name and address information appears on the page."""
with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
]
self.user.save()
self.user.refresh_from_db()
self.portfolio.organization_name = "Hotel California"
self.portfolio.save()
page = self.app.get(reverse("portfolio-organization", kwargs={"portfolio_id": self.portfolio.pk}))
# Once in the sidenav, once in the main nav, once in the form
self.assertContains(page, "Hotel California", count=3)
page = self.app.get(reverse("organization"))
# Once in the sidenav, once in the main nav
self.assertContains(page, "Hotel California", count=2)
self.assertContains(page, "Non-Federal Agency")
@less_console_noise_decorator
def test_domain_org_name_address_form(self):
"""Submitting changes works on the org name address page."""
with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
]
self.user.save()
self.user.refresh_from_db()
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"

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

View file

@ -17,3 +17,4 @@ from .domain import (
from .user_profile import UserProfileView, FinishProfileSetupView
from .health import *
from .index import *
from .portfolios import *

View file

@ -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"""

View file

@ -124,7 +124,7 @@ def serialize_domain(domain, user):
# 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,
@ -133,11 +133,7 @@ def serialize_domain(domain, user):
"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 not user_domain_role_exists or 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,
}

View file

@ -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,15 @@ 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")
context["has_edit_org_portfolio_permission"] = self.request.user.has_edit_org_portfolio_permission()
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 +93,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")

View file

@ -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"""

View file

@ -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