Merge branch 'za/2586-infra-for-multiple-portfolios' of https://github.com/cisagov/manage.get.gov into za/2586-infra-for-multiple-portfolios

This commit is contained in:
David Kennedy 2024-08-21 17:08:40 -04:00
commit 75ff4a591c
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
20 changed files with 407 additions and 54 deletions

View file

@ -9,6 +9,7 @@ from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_information import DomainInformation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
from django.contrib import admin, messages from django.contrib import admin, messages
@ -3157,12 +3158,32 @@ class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["name", "portfolio"] list_display = ["name", "portfolio"]
autocomplete_fields = [ autocomplete_fields = [
"portfolio", "portfolio",
] ]
search_fields = ["name"] search_fields = ["name"]
change_form_template = "django/admin/suborg_change_form.html"
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add suborg's related domains and requests to context"""
obj = self.get_object(request, object_id)
# ---- Domain Requests
domain_requests = DomainRequest.objects.filter(sub_organization=obj)
sort_by = request.GET.get("sort_by", "requested_domain__name")
domain_requests = domain_requests.order_by(sort_by)
# ---- Domains
domain_infos = DomainInformation.objects.filter(sub_organization=obj)
domain_ids = domain_infos.values_list("domain", flat=True)
domains = Domain.objects.filter(id__in=domain_ids).exclude(state=Domain.State.DELETED)
extra_context = {"domain_requests": domain_requests, "domains": domains}
return super().change_view(request, object_id, form_url, extra_context)
admin.site.unregister(LogEntry) # Unregister the default registration admin.site.unregister(LogEntry) # Unregister the default registration

View file

@ -356,9 +356,18 @@ CSP_FORM_ACTION = allowed_sources
# strict CSP by allowing scripts to run from their domain # strict CSP by allowing scripts to run from their domain
# and inline with a nonce, as well as allowing connections back to their domain. # and inline with a nonce, as well as allowing connections back to their domain.
# Note: If needed, we can embed chart.js instead of using the CDN # Note: If needed, we can embed chart.js instead of using the CDN
CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/", "https://cdn.jsdelivr.net/npm/chart.js"] CSP_DEFAULT_SRC = ("'self'",)
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/"] CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem"] CSP_SCRIPT_SRC_ELEM = [
"'self'",
"https://www.googletagmanager.com/",
"https://cdn.jsdelivr.net/npm/chart.js",
"https://www.ssa.gov",
"https://ajax.googleapis.com",
]
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"]
CSP_IMG_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/icons/"]
# Cross-Origin Resource Sharing (CORS) configuration # Cross-Origin Resource Sharing (CORS) configuration
# Sets clients that allow access control to manage.get.gov # Sets clients that allow access control to manage.get.gov

View file

@ -65,6 +65,11 @@ urlpatterns = [
views.PortfolioDomainsView.as_view(), views.PortfolioDomainsView.as_view(),
name="domains", name="domains",
), ),
path(
"no-organization-domains/",
views.PortfolioNoDomainsView.as_view(),
name="no-portfolio-domains",
),
path( path(
"requests/", "requests/",
views.PortfolioDomainRequestsView.as_view(), views.PortfolioDomainRequestsView.as_view(),

View file

@ -22,6 +22,12 @@ class UserFixture:
""" """
ADMINS = [ ADMINS = [
{
"username": "43a7fa8d-0550-4494-a6fe-81500324d590",
"first_name": "Jyoti",
"last_name": "Bock",
"email": "jyotibock@truss.works",
},
{ {
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf", "username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
"first_name": "Aditi", "first_name": "Aditi",
@ -125,6 +131,12 @@ class UserFixture:
] ]
STAFF = [ STAFF = [
{
"username": "a5906815-dd80-4c64-aebe-2da6a4c9d7a4",
"first_name": "Jyoti-Analyst",
"last_name": "Bock-Analyst",
"email": "jyotibock+1@truss.works",
},
{ {
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54", "username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
"first_name": "Aditi-Analyst", "first_name": "Aditi-Analyst",

View file

@ -679,7 +679,7 @@ class CisaRepresentativeYesNoForm(BaseYesNoForm):
field_name = "has_cisa_representative" field_name = "has_cisa_representative"
class AdditionalDetailsForm(BaseDeletableRegistrarForm): class AnythingElseForm(BaseDeletableRegistrarForm):
anything_else = forms.CharField( anything_else = forms.CharField(
required=True, required=True,
label="Anything else?", label="Anything else?",
@ -698,7 +698,7 @@ class AdditionalDetailsForm(BaseDeletableRegistrarForm):
) )
class AdditionalDetailsYesNoForm(BaseYesNoForm): class AnythingElseYesNoForm(BaseYesNoForm):
"""Yes/no toggle for the anything else question on additional details""" """Yes/no toggle for the anything else question on additional details"""
# Note that these can be set as functions/init if you need more fine-grained control. # Note that these can be set as functions/init if you need more fine-grained control.

View file

@ -296,23 +296,29 @@ class DomainInformation(TimeStampedModel):
"""Some yes/no forms use a db field to track whether it was checked or not. """Some yes/no forms use a db field to track whether it was checked or not.
We handle that here for def save(). We handle that here for def save().
""" """
# Check if the firstname or lastname of cisa representative has any data.
# Then set the has_cisa_representative flag accordingly (so that it isn't
# "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated # This ensures that if we have prefilled data, the form is prepopulated
if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None: if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None:
self.has_cisa_representative = ( self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_last_name != "" self.cisa_representative_first_name != "" and self.cisa_representative_last_name != ""
) )
# This check is required to ensure that the form doesn't start out checked # Check for blank data and update has_cisa_representative accordingly (if it isn't None)
if self.has_cisa_representative is not None: if self.has_cisa_representative is not None:
self.has_cisa_representative = ( self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None
) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None) ) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None)
# Check if anything_else has any data.
# Then set the has_anything_else_text flag accordingly (so that it isn't
# "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated # This ensures that if we have prefilled data, the form is prepopulated
if self.anything_else is not None: if self.anything_else is not None:
self.has_anything_else_text = self.anything_else != "" self.has_anything_else_text = self.anything_else != ""
# This check is required to ensure that the form doesn't start out checked. # Check for blank data and update has_anything_else_text accordingly (if it isn't None)
if self.has_anything_else_text is not None: if self.has_anything_else_text is not None:
self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None

View file

@ -645,23 +645,29 @@ class DomainRequest(TimeStampedModel):
"""Some yes/no forms use a db field to track whether it was checked or not. """Some yes/no forms use a db field to track whether it was checked or not.
We handle that here for def save(). We handle that here for def save().
""" """
# Check if the firstname or lastname of cisa representative has any data.
# Then set the has_cisa_representative flag accordingly (so that it isn't
# "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated # This ensures that if we have prefilled data, the form is prepopulated
if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None: if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None:
self.has_cisa_representative = ( self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_last_name != "" self.cisa_representative_first_name != "" and self.cisa_representative_last_name != ""
) )
# This check is required to ensure that the form doesn't start out checked # Check for blank data and update has_cisa_representative accordingly (if it isn't None)
if self.has_cisa_representative is not None: if self.has_cisa_representative is not None:
self.has_cisa_representative = ( self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None
) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None) ) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None)
# Check if anything_else has any data.
# Then set the has_anything_else_text flag accordingly (so that it isn't
# "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated # This ensures that if we have prefilled data, the form is prepopulated
if self.anything_else is not None: if self.anything_else is not None:
self.has_anything_else_text = self.anything_else != "" self.has_anything_else_text = self.anything_else != ""
# This check is required to ensure that the form doesn't start out checked. # Check for blank data and update has_anything_else_text accordingly (if it isn't None)
if self.has_anything_else_text is not None: if self.has_anything_else_text is not None:
self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None

View file

@ -161,8 +161,8 @@ class CheckPortfolioMiddleware:
if request.user.has_domains_portfolio_permission(request.session["portfolio"]): if request.user.has_domains_portfolio_permission(request.session["portfolio"]):
portfolio_redirect = reverse("domains") portfolio_redirect = reverse("domains")
else: else:
# View organization is the lowest access portfolio_redirect = reverse("no-portfolio-domains")
portfolio_redirect = reverse("organization")
return HttpResponseRedirect(portfolio_redirect) return HttpResponseRedirect(portfolio_redirect)
return None return None

View file

@ -0,0 +1,36 @@
{% extends 'django/admin/email_clipboard_change_form.html' %}
{% load i18n static %}
{% block after_related_objects %}
<div class="module aligned padding-3">
<h2>Associated requests and domains</h2>
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
<h3>Domain requests</h3>
<ul class="margin-0 padding-0">
{% for domain_request in domain_requests %}
<li>
<a href="{% url 'admin:registrar_domainrequest_change' domain_request.pk %}">
{{ domain_request.requested_domain }}
</a>
({{ domain_request.status }})
</li>
{% endfor %}
</ul>
</div>
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
<h3>Domains</h3>
<ul class="margin-0 padding-0">
{% for domain in domains %}
<li>
<a href="{% url 'admin:registrar_domain_change' domain.pk %}">
{{ domain.name }}
</a>
({{ domain.state }})
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock %}

View file

@ -5,16 +5,16 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domains_json' as url %} {% url 'get_domains_json' as url %}
<span id="get_domains_json_url" class="display-none">{{url}}</span> <span id="get_domains_json_url" class="display-none">{{url}}</span>
<section class="section--outlined domains{% if not has_domains_portfolio_permission %} margin-top-0{% endif %}" id="domains"> <section class="section--outlined domains{% if not portfolio %} 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 %}"> <div class="section--outlined__header margin-bottom-3 {% if not portfolio %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not has_domains_portfolio_permission %} {% if not portfolio %}
<h2 id="domains-header" class="display-inline-block">Domains</h2> <h2 id="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span> <span class="display-none" id="no-portfolio-js-flag"></span>
{% else %} {% else %}
<!-- Embedding the portfolio value in a data attribute --> <!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span> <span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %} {% endif %}
<div class="section--outlined__search {% if has_domains_portfolio_permission %} mobile:grid-col-12 desktop:grid-col-6{% 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"> <section aria-label="Domains search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
@ -43,7 +43,7 @@
</section> </section>
</div> </div>
{% if user_domain_count and user_domain_count > 0 %} {% if user_domain_count and user_domain_count > 0 %}
<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 %}"> <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 %}">
<section aria-label="Domains report component" class="mobile-lg:margin-top-205"> <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"> <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"> <svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
@ -54,7 +54,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if has_domains_portfolio_permission %} {% if portfolio %}
<div class="display-flex flex-align-center"> <div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span> <span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2"> <div class="usa-accordion usa-accordion--select margin-right-2">
@ -157,7 +157,7 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th> <th data-sortable="name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th> <th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</th> <th data-sortable="state_display" scope="col" role="columnheader">Status</th>
{% if has_domains_portfolio_permission and has_view_suborganization %} {% if portfolio and has_view_suborganization %}
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th> <th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
{% endif %} {% endif %}
<th <th

View file

@ -13,19 +13,22 @@
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" /> <img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button> </button>
<ul class="usa-nav__primary usa-accordion"> <ul class="usa-nav__primary usa-accordion">
{% if has_domains_portfolio_permission %} <li class="usa-nav__primary-item">
<li class="usa-nav__primary-item"> {% if has_domains_portfolio_permission %}
{% url 'domains' as url %} {% url 'domains' as url %}
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}"> {%else %}
Domains {% url 'no-portfolio-domains' as url %}
</a> {% endif %}
</li> <a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
{% endif %} Domains
</a>
</li>
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link"> <a href="#" class="usa-nav-link">
Domain groups Domain groups
</a> </a>
</li> </li>
{% if has_domain_requests_portfolio_permission %} {% if has_domain_requests_portfolio_permission %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
{% url 'domain-requests' as url %} {% url 'domain-requests' as url %}

View file

@ -0,0 +1,30 @@
{% extends 'portfolio_base.html' %}
{% load static %}
{% block title %} Domains | {% endblock %}
{% block portfolio_content %}
<h1 id="domains-header">Domains</h1>
<section class="section--outlined">
<div class="section--outlined__header margin-bottom-3">
<h2 id="domains-header" class="display-inline-block">You arent managing any domains.</h2>
{% if portfolio_administrators %}
<p>If you believe you should have access to a domain, reach out to your organizations administrators.</p>
<p>Your organizations administrators:</p>
<ul class="margin-top-0">
{% for administrator in portfolio_administrators %}
{% if administrator.email %}
<li>{{ administrator.email }}</li>
{% else %}
<li>{{ administrator }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<p><strong>No administrators were found on your organization.</strong></p>
<p>If you believe you should have access to a domain, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.</p>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -15,7 +15,7 @@ from registrar.forms.domain_request_wizard import (
RequirementsForm, RequirementsForm,
TribalGovernmentForm, TribalGovernmentForm,
PurposeForm, PurposeForm,
AdditionalDetailsForm, AnythingElseForm,
AboutYourOrganizationForm, AboutYourOrganizationForm,
) )
from registrar.forms.domain import ContactForm from registrar.forms.domain import ContactForm
@ -274,7 +274,7 @@ class TestFormValidation(MockEppLib):
def test_anything_else_form_about_your_organization_character_count_invalid(self): def test_anything_else_form_about_your_organization_character_count_invalid(self):
"""Response must be less than 2000 characters.""" """Response must be less than 2000 characters."""
form = AdditionalDetailsForm( form = AnythingElseForm(
data={ data={
"anything_else": "Bacon ipsum dolor amet fatback strip steak pastrami" "anything_else": "Bacon ipsum dolor amet fatback strip steak pastrami"
"shankle, drumstick doner chicken landjaeger turkey andouille." "shankle, drumstick doner chicken landjaeger turkey andouille."

View file

@ -1229,6 +1229,71 @@ class TestPortfolioInvitations(TestCase):
self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
class TestUserPortfolioPermission(TestCase):
@less_console_noise_decorator
def setUp(self):
self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov")
super().setUp()
def tearDown(self):
super().tearDown()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
UserDomainRole.objects.all().delete()
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=True)
def test_clean_on_multiple_portfolios_when_flag_active(self):
"""Ensures that a user can create multiple portfolio permission objects when the flag is enabled"""
# Create an instance of User with a portfolio but no roles or additional permissions
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California")
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
portfolio_permission_2 = UserPortfolioPermission(
portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Clean should pass on both of these objects
try:
portfolio_permission.clean()
portfolio_permission_2.clean()
except ValidationError as error:
self.fail(f"Raised ValidationError unexpectedly: {error}")
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=False)
def test_clean_on_creates_multiple_portfolios(self):
"""Ensures that a user cannot create multiple portfolio permission objects when the flag is disabled"""
# Create an instance of User with a portfolio but no roles or additional permissions
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California")
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
portfolio_permission_2 = UserPortfolioPermission(
portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# This should work as intended
portfolio_permission.clean()
# Test if the ValidationError is raised with the correct message
with self.assertRaises(ValidationError) as cm:
portfolio_permission_2.clean()
portfolio_permission_2, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
self.assertEqual(
cm.exception.message, "Only one portfolio permission is allowed per user when multiple portfolios are disabled."
)
class TestUser(TestCase): class TestUser(TestCase):
"""Test actions that occur on user login, """Test actions that occur on user login,
test class method that controls how users get validated.""" test class method that controls how users get validated."""

View file

@ -12,7 +12,7 @@ from registrar.models import (
) )
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .common import create_test_user from .common import create_test_user, get_wsgi_request_object
from waffle.testutils import override_flag from waffle.testutils import override_flag
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
@ -101,8 +101,8 @@ class TestPortfolio(WebTest):
self.assertNotContains(portfolio_page, self.portfolio.organization_name) self.assertNotContains(portfolio_page, self.portfolio.organization_name)
@less_console_noise_decorator @less_console_noise_decorator
def test_middleware_redirects_to_portfolio_organization_page(self): def test_middleware_redirects_to_portfolio_no_domains_page(self):
"""Test that user with a portfolio and VIEW_PORTFOLIO is redirected to portfolio organization page""" """Test that user with a portfolio and VIEW_PORTFOLIO is redirected to the no domains page"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
UserPortfolioPermission.objects.get_or_create( UserPortfolioPermission.objects.get_or_create(
user=self.user, user=self.user,
@ -115,7 +115,8 @@ class TestPortfolio(WebTest):
portfolio_page = self.app.get(reverse("home")).follow() portfolio_page = self.app.get(reverse("home")).follow()
# Assert that we're on the right page # Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name) self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Organization</h1>") self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
self.assertContains(portfolio_page, "You arent managing any domains")
@less_console_noise_decorator @less_console_noise_decorator
def test_middleware_redirects_to_portfolio_domains_page(self): def test_middleware_redirects_to_portfolio_domains_page(self):
@ -229,8 +230,8 @@ class TestPortfolio(WebTest):
self.assertContains(response, 'for="id_city"') self.assertContains(response, 'for="id_city"')
@less_console_noise_decorator @less_console_noise_decorator
def test_navigation_links_hidden_when_user_not_have_permission(self): def test_accessible_pages_when_user_does_not_have_permission(self):
"""Test that navigation links are hidden when user does not have portfolio permissions""" """Tests which pages are accessible when user does not have portfolio permissions"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
portfolio_additional_permissions = [ portfolio_additional_permissions = [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
@ -257,16 +258,29 @@ class TestPortfolio(WebTest):
portfolio_permission.save() portfolio_permission.save()
portfolio_permission.refresh_from_db() portfolio_permission.refresh_from_db()
# Members should be redirected to the readonly domains page
portfolio_page = self.app.get(reverse("home")).follow() portfolio_page = self.app.get(reverse("home")).follow()
self.assertContains(portfolio_page, self.portfolio.organization_name) self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Organization</h1>") self.assertNotContains(portfolio_page, "<h1>Organization</h1>")
self.assertNotContains(portfolio_page, '<h1 id="domains-header">Domains</h1>') self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
self.assertContains(portfolio_page, "You arent managing any domains")
self.assertNotContains(portfolio_page, reverse("domains")) self.assertNotContains(portfolio_page, reverse("domains"))
self.assertNotContains(portfolio_page, reverse("domain-requests")) self.assertNotContains(portfolio_page, reverse("domain-requests"))
# The organization page should still be accessible
org_page = self.app.get(reverse("organization"))
self.assertContains(org_page, self.portfolio.organization_name)
self.assertContains(org_page, "<h1>Organization</h1>")
# Both domain pages should not be accessible
domain_page = self.app.get(reverse("domains"), expect_errors=True)
self.assertEquals(domain_page.status_code, 403)
domain_request_page = self.app.get(reverse("domain-requests"), expect_errors=True)
self.assertEquals(domain_request_page.status_code, 403)
@less_console_noise_decorator @less_console_noise_decorator
def test_navigation_links_hidden_when_user_not_have_role(self): def test_accessible_pages_when_user_does_not_have_role(self):
"""Test that admin / memmber roles are associated with the right access""" """Test that admin / memmber roles are associated with the right access"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
@ -290,14 +304,27 @@ class TestPortfolio(WebTest):
portfolio_permission.save() portfolio_permission.save()
portfolio_permission.refresh_from_db() portfolio_permission.refresh_from_db()
# Members should be redirected to the readonly domains page
portfolio_page = self.app.get(reverse("home")).follow() portfolio_page = self.app.get(reverse("home")).follow()
self.assertContains(portfolio_page, self.portfolio.organization_name) self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Organization</h1>") self.assertNotContains(portfolio_page, "<h1>Organization</h1>")
self.assertNotContains(portfolio_page, '<h1 id="domains-header">Domains</h1>') self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
self.assertContains(portfolio_page, "You arent managing any domains")
self.assertNotContains(portfolio_page, reverse("domains")) self.assertNotContains(portfolio_page, reverse("domains"))
self.assertNotContains(portfolio_page, reverse("domain-requests")) self.assertNotContains(portfolio_page, reverse("domain-requests"))
# The organization page should still be accessible
org_page = self.app.get(reverse("organization"))
self.assertContains(org_page, self.portfolio.organization_name)
self.assertContains(org_page, "<h1>Organization</h1>")
# Both domain pages should not be accessible
domain_page = self.app.get(reverse("domains"), expect_errors=True)
self.assertEquals(domain_page.status_code, 403)
domain_request_page = self.app.get(reverse("domain-requests"), expect_errors=True)
self.assertEquals(domain_request_page.status_code, 403)
@less_console_noise_decorator @less_console_noise_decorator
def test_portfolio_org_name(self): def test_portfolio_org_name(self):
"""Can load portfolio's org name page.""" """Can load portfolio's org name page."""
@ -465,3 +492,48 @@ class TestPortfolio(WebTest):
assert 'portfolio' in session, "Portfolio session variable should exist." assert 'portfolio' in session, "Portfolio session variable should exist."
# Check the value of the 'portfolio' session variable # Check the value of the 'portfolio' session variable
self.assertIsNone(session['portfolio']) self.assertIsNone(session['portfolio'])
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_org_member_can_only_see_domains_with_appropriate_permissions(self):
"""A user with the role organization_member should not have access to the domains page
if they do not have the right permissions.
"""
# A default organization member should not be able to see any domains
self.app.set_user(self.user.username)
permission, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
response = self.app.get(reverse("no-portfolio-domains"))
self.assertFalse(self.user.has_domains_portfolio_permission(response.request.get("portfolio")))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "You arent managing any domains.")
# Test the domains page - this user should not have access
response = self.app.get(reverse("domains"), expect_errors=True)
self.assertEqual(response.status_code, 403)
# Ensure that this user can see domains with the right permissions
permission.additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
permission.save()
permission.refresh_from_db()
# Test the domains page - this user should have access
response = self.app.get(reverse("domains"))
self.assertTrue(self.user.has_domains_portfolio_permission(response.request.get("portfolio")))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Domain name")
# Test the managed domains permission
permission.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS]
permission.save()
permission.refresh_from_db()
# Test the domains page - this user should have access
response = self.app.get(reverse("domains"))
self.assertTrue(self.user.has_domains_portfolio_permission(response.request.get("portfolio")))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Domain name")
permission.delete()

View file

@ -1017,20 +1017,27 @@ class DomainRequestTests(TestWithUser, WebTest):
type_page = intro_result.follow() type_page = intro_result.follow()
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
# fill out the organization type section then submit
type_form = type_page.forms[0] type_form = type_page.forms[0]
type_form["generic_org_type-generic_org_type"] = "federal" type_form["generic_org_type-generic_org_type"] = "federal"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
type_result = type_form.submit() type_result = type_form.submit()
# follow first redirect # follow first redirect to the next section
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
federal_page = type_result.follow() federal_page = type_result.follow()
# Now on federal type page, click back to the organization type # we need to fill out the federal section so it stays unlocked
fed_branch_form = federal_page.forms[0]
fed_branch_form["organization_federal-federal_type"] = "executive"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
fed_branch_form.submit()
# Now click back to the organization type
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
new_page = federal_page.click(str(self.TITLES["generic_org_type"]), index=0) new_page = federal_page.click(str(self.TITLES["generic_org_type"]), index=0)
# Should be a link to the organization_federal page # Should be a link to the organization_federal page since it is now unlocked
self.assertGreater( self.assertGreater(
len(new_page.html.find_all("a", href="/request/organization_federal/")), len(new_page.html.find_all("a", href="/request/organization_federal/")),
0, 0,
@ -2528,9 +2535,22 @@ class DomainRequestTests(TestWithUser, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
election_page = type_result.follow() election_page = type_result.follow()
# Go back to SO page and test the dynamic text changed # Navigate to the org page as that is the step right before senior_official
org_page = election_page.click(str(self.TITLES["organization_contact"]), index=0)
org_contact_form = org_page.forms[0]
org_contact_form["organization_contact-organization_name"] = "Testorg"
org_contact_form["organization_contact-address_line1"] = "address 1"
org_contact_form["organization_contact-address_line2"] = "address 2"
org_contact_form["organization_contact-city"] = "NYC"
org_contact_form["organization_contact-state_territory"] = "NY"
org_contact_form["organization_contact-zipcode"] = "10002"
org_contact_form["organization_contact-urbanization"] = "URB Royal Oaks"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
so_page = election_page.click(str(self.TITLES["senior_official"]), index=0) org_contact_result = org_contact_form.submit()
# Navigate back to the so page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
so_page = org_contact_result.follow()
self.assertContains(so_page, "Domain requests from cities") self.assertContains(so_page, "Domain requests from cities")
@less_console_noise_decorator @less_console_noise_decorator
@ -2628,9 +2648,15 @@ class DomainRequestTests(TestWithUser, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
election_page = type_result.follow() election_page = type_result.follow()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
current_websites = election_page.click(str(self.TITLES["current_sites"]), index=0)
current_sites_form = current_websites.forms[0]
current_sites_form["current_sites-0-website"] = "www.city.com"
current_sites_result = current_sites_form.submit().follow()
# Go back to dotgov domain page to test the dynamic text changed # Go back to dotgov domain page to test the dynamic text changed
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
dotgov_page = election_page.click(str(self.TITLES["dotgov_domain"]), index=0) dotgov_page = current_sites_result.click(str(self.TITLES["dotgov_domain"]), index=0)
self.assertContains(dotgov_page, "CityofEudoraKS.gov") self.assertContains(dotgov_page, "CityofEudoraKS.gov")
self.assertNotContains(dotgov_page, "medicare.gov") self.assertNotContains(dotgov_page, "medicare.gov")
@ -2984,6 +3010,9 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
"""Test when all fields in the domain request are filled.""" """Test when all fields in the domain request are filled."""
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED, user=self.user) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED, user=self.user)
domain_request.anything_else = False
domain_request.has_anything_else_text = False
domain_request.save()
response = self.app.get(f"/domain-request/{domain_request.id}/edit/") response = self.app.get(f"/domain-request/{domain_request.id}/edit/")
# django-webtest does not handle cookie-based sessions well because it keeps # django-webtest does not handle cookie-based sessions well because it keeps

View file

@ -217,7 +217,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
if current_url == self.EDIT_URL_NAME and "id" in kwargs: if current_url == self.EDIT_URL_NAME and "id" in kwargs:
del self.storage del self.storage
self.storage["domain_request_id"] = kwargs["id"] self.storage["domain_request_id"] = kwargs["id"]
self.storage["step_history"] = self.db_check_for_unlocking_steps()
# if accessing this class directly, redirect to either to an acknowledgement # if accessing this class directly, redirect to either to an acknowledgement
# page or to the first step in the processes (if an edit rather than a new request); # page or to the first step in the processes (if an edit rather than a new request);
@ -233,6 +232,9 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
else: else:
return self.goto(self.steps.first) return self.goto(self.steps.first)
# refresh step_history to ensure we don't erroneously unlock unfinished
# steps just because we visited it
self.storage["step_history"] = self.db_check_for_unlocking_steps()
context = self.get_context_data() context = self.get_context_data()
self.steps.current = current_url self.steps.current = current_url
context["forms"] = self.get_forms() context["forms"] = self.get_forms()
@ -341,6 +343,17 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"""Helper for get_context_data """Helper for get_context_data
Queries the DB for a domain request and returns a list of unlocked steps.""" Queries the DB for a domain request and returns a list of unlocked steps."""
# The way this works is as follows:
# Each step is assigned a true/false value to determine if it is
# "unlocked" or not. This dictionary of values is looped through
# at the end of this function and any step with a "true" value is
# added to a simple array that is returned at the end of this function.
# This array is eventually passed to the frontend context (eg. domain_request_sidebar.html),
# and is used to determine how steps appear in the side nav.
# It is worth noting that any step assigned "false" here will be EXCLUDED
# from the list of "unlocked" steps.
history_dict = { history_dict = {
"generic_org_type": self.domain_request.generic_org_type is not None, "generic_org_type": self.domain_request.generic_org_type is not None,
"tribal_government": self.domain_request.tribe_name is not None, "tribal_government": self.domain_request.tribe_name is not None,
@ -368,8 +381,11 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
or self.domain_request.no_other_contacts_rationale is not None or self.domain_request.no_other_contacts_rationale is not None
), ),
"additional_details": ( "additional_details": (
(self.domain_request.anything_else is not None and self.domain_request.has_cisa_representative) # Additional details is complete as long as "has anything else" and "has cisa rep" are not None
or self.domain_request.is_policy_acknowledged is not None (
self.domain_request.has_anything_else_text is not None
and self.domain_request.has_cisa_representative is not None
)
), ),
"requirements": self.domain_request.is_policy_acknowledged is not None, "requirements": self.domain_request.is_policy_acknowledged is not None,
"review": self.domain_request.is_policy_acknowledged is not None, "review": self.domain_request.is_policy_acknowledged is not None,
@ -626,8 +642,8 @@ class AdditionalDetails(DomainRequestWizard):
forms = [ forms = [
forms.CisaRepresentativeYesNoForm, forms.CisaRepresentativeYesNoForm,
forms.CisaRepresentativeForm, forms.CisaRepresentativeForm,
forms.AdditionalDetailsYesNoForm, forms.AnythingElseYesNoForm,
forms.AdditionalDetailsForm, forms.AnythingElseForm,
] ]
def is_valid(self, forms: list) -> bool: def is_valid(self, forms: list) -> bool:

View file

@ -4,11 +4,14 @@ from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from django.contrib import messages from django.contrib import messages
from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm
from registrar.models.portfolio import Portfolio from registrar.models import Portfolio, User
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.views.utility.permission_views import ( from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView, PortfolioDomainRequestsPermissionView,
PortfolioDomainsPermissionView, PortfolioDomainsPermissionView,
PortfolioBasePermissionView, PortfolioBasePermissionView,
NoPortfolioDomainsPermissionView,
) )
from django.views.generic import View from django.views.generic import View
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
@ -38,6 +41,35 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
return render(request, "portfolio_requests.html") return render(request, "portfolio_requests.html")
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domains.
This is a custom view which explains that to the user - and denotes who to contact.
"""
model = Portfolio
template_name = "no_portfolio_domains.html"
def get(self, request):
return render(request, self.template_name, context=self.get_context_data())
def get_context_data(self, **kwargs):
"""Add additional context data to the template."""
# We can override the base class. This view only needs this item.
context = {}
portfolio = self.request.session.get("portfolio")
if portfolio:
admin_ids = UserPortfolioPermission.objects.filter(
portfolio=portfolio,
roles__overlap=[
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
]
).values_list("user__id", flat=True)
admin_users = User.objects.filter(id__in=admin_ids)
context["portfolio_administrators"] = admin_users
return context
class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin): class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
""" """
View to handle displaying and updating the portfolio's organization details. View to handle displaying and updating the portfolio's organization details.

View file

@ -432,10 +432,11 @@ class PortfolioDomainsPermission(PortfolioBasePermission):
The user is in self.request.user and the portfolio can be looked The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]""" up from the portfolio's primary key in self.kwargs["pk"]"""
if not self.request.user.is_authenticated: portfolio = self.request.session.get("portfolio")
if not self.request.user.has_domains_portfolio_permission(portfolio):
return False return False
return self.request.user.is_org_user(self.request) return super().has_permission()
class PortfolioDomainRequestsPermission(PortfolioBasePermission): class PortfolioDomainRequestsPermission(PortfolioBasePermission):
@ -448,7 +449,8 @@ class PortfolioDomainRequestsPermission(PortfolioBasePermission):
The user is in self.request.user and the portfolio can be looked The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]""" up from the portfolio's primary key in self.kwargs["pk"]"""
if not self.request.user.is_authenticated: portfolio = self.request.session.get("portfolio")
if not self.request.user.has_domain_requests_portfolio_permission(portfolio):
return False return False
return self.request.user.is_org_user(self.request) return super().has_permission()

View file

@ -214,6 +214,15 @@ class PortfolioDomainsPermissionView(PortfolioDomainsPermission, PortfolioBasePe
""" """
class NoPortfolioDomainsPermissionView(PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for a user without access to the
portfolio domains views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, PortfolioBasePermissionView, abc.ABC): class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for portfolio domain request views that enforces permissions. """Abstract base view for portfolio domain request views that enforces permissions.