mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-26 04:28:39 +02:00
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:
commit
75ff4a591c
20 changed files with 407 additions and 54 deletions
|
@ -9,6 +9,7 @@ from django.db.models.functions import Concat, Coalesce
|
|||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
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 waffle.decorators import flag_is_active
|
||||
from django.contrib import admin, messages
|
||||
|
@ -3157,12 +3158,32 @@ class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
|
||||
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
|
||||
list_display = ["name", "portfolio"]
|
||||
autocomplete_fields = [
|
||||
"portfolio",
|
||||
]
|
||||
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
|
||||
|
||||
|
|
|
@ -356,9 +356,18 @@ CSP_FORM_ACTION = allowed_sources
|
|||
# strict CSP by allowing scripts to run from 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
|
||||
CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/", "https://cdn.jsdelivr.net/npm/chart.js"]
|
||||
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/"]
|
||||
CSP_INCLUDE_NONCE_IN = ["script-src-elem"]
|
||||
CSP_DEFAULT_SRC = ("'self'",)
|
||||
CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"]
|
||||
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
|
||||
# Sets clients that allow access control to manage.get.gov
|
||||
|
|
|
@ -65,6 +65,11 @@ urlpatterns = [
|
|||
views.PortfolioDomainsView.as_view(),
|
||||
name="domains",
|
||||
),
|
||||
path(
|
||||
"no-organization-domains/",
|
||||
views.PortfolioNoDomainsView.as_view(),
|
||||
name="no-portfolio-domains",
|
||||
),
|
||||
path(
|
||||
"requests/",
|
||||
views.PortfolioDomainRequestsView.as_view(),
|
||||
|
|
|
@ -22,6 +22,12 @@ class UserFixture:
|
|||
"""
|
||||
|
||||
ADMINS = [
|
||||
{
|
||||
"username": "43a7fa8d-0550-4494-a6fe-81500324d590",
|
||||
"first_name": "Jyoti",
|
||||
"last_name": "Bock",
|
||||
"email": "jyotibock@truss.works",
|
||||
},
|
||||
{
|
||||
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
|
||||
"first_name": "Aditi",
|
||||
|
@ -125,6 +131,12 @@ class UserFixture:
|
|||
]
|
||||
|
||||
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",
|
||||
"first_name": "Aditi-Analyst",
|
||||
|
|
|
@ -679,7 +679,7 @@ class CisaRepresentativeYesNoForm(BaseYesNoForm):
|
|||
field_name = "has_cisa_representative"
|
||||
|
||||
|
||||
class AdditionalDetailsForm(BaseDeletableRegistrarForm):
|
||||
class AnythingElseForm(BaseDeletableRegistrarForm):
|
||||
anything_else = forms.CharField(
|
||||
required=True,
|
||||
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"""
|
||||
|
||||
# Note that these can be set as functions/init if you need more fine-grained control.
|
||||
|
|
|
@ -296,23 +296,29 @@ class DomainInformation(TimeStampedModel):
|
|||
"""Some yes/no forms use a db field to track whether it was checked or not.
|
||||
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
|
||||
if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None:
|
||||
self.has_cisa_representative = (
|
||||
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:
|
||||
self.has_cisa_representative = (
|
||||
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)
|
||||
|
||||
# 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
|
||||
if self.anything_else is not None:
|
||||
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:
|
||||
self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None
|
||||
|
||||
|
|
|
@ -645,23 +645,29 @@ class DomainRequest(TimeStampedModel):
|
|||
"""Some yes/no forms use a db field to track whether it was checked or not.
|
||||
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
|
||||
if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None:
|
||||
self.has_cisa_representative = (
|
||||
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:
|
||||
self.has_cisa_representative = (
|
||||
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)
|
||||
|
||||
# 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
|
||||
if self.anything_else is not None:
|
||||
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:
|
||||
self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None
|
||||
|
||||
|
|
|
@ -161,8 +161,8 @@ class CheckPortfolioMiddleware:
|
|||
if request.user.has_domains_portfolio_permission(request.session["portfolio"]):
|
||||
portfolio_redirect = reverse("domains")
|
||||
else:
|
||||
# View organization is the lowest access
|
||||
portfolio_redirect = reverse("organization")
|
||||
portfolio_redirect = reverse("no-portfolio-domains")
|
||||
|
||||
return HttpResponseRedirect(portfolio_redirect)
|
||||
|
||||
return None
|
||||
|
|
36
src/registrar/templates/django/admin/suborg_change_form.html
Normal file
36
src/registrar/templates/django/admin/suborg_change_form.html
Normal 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 %}
|
|
@ -5,16 +5,16 @@
|
|||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get_domains_json' as url %}
|
||||
<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">
|
||||
<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 %}
|
||||
<section class="section--outlined domains{% if not portfolio %} margin-top-0{% endif %}" id="domains">
|
||||
<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 portfolio %}
|
||||
<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 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">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
|
@ -43,7 +43,7 @@
|
|||
</section>
|
||||
</div>
|
||||
{% 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">
|
||||
<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">
|
||||
|
@ -54,7 +54,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if has_domains_portfolio_permission %}
|
||||
{% if portfolio %}
|
||||
<div class="display-flex flex-align-center">
|
||||
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
|
||||
<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="expiration_date" scope="col" role="columnheader">Expires</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>
|
||||
{% endif %}
|
||||
<th
|
||||
|
|
|
@ -13,19 +13,22 @@
|
|||
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
|
||||
</button>
|
||||
<ul class="usa-nav__primary usa-accordion">
|
||||
{% if has_domains_portfolio_permission %}
|
||||
<li class="usa-nav__primary-item">
|
||||
{% if has_domains_portfolio_permission %}
|
||||
{% url 'domains' as url %}
|
||||
{%else %}
|
||||
{% url 'no-portfolio-domains' as url %}
|
||||
{% endif %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domains
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Domain groups
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if has_domain_requests_portfolio_permission %}
|
||||
<li class="usa-nav__primary-item">
|
||||
{% url 'domain-requests' as url %}
|
||||
|
|
30
src/registrar/templates/no_portfolio_domains.html
Normal file
30
src/registrar/templates/no_portfolio_domains.html
Normal 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 aren’t managing any domains.</h2>
|
||||
{% if portfolio_administrators %}
|
||||
<p>If you believe you should have access to a domain, reach out to your organization’s 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 %}
|
|
@ -15,7 +15,7 @@ from registrar.forms.domain_request_wizard import (
|
|||
RequirementsForm,
|
||||
TribalGovernmentForm,
|
||||
PurposeForm,
|
||||
AdditionalDetailsForm,
|
||||
AnythingElseForm,
|
||||
AboutYourOrganizationForm,
|
||||
)
|
||||
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):
|
||||
"""Response must be less than 2000 characters."""
|
||||
form = AdditionalDetailsForm(
|
||||
form = AnythingElseForm(
|
||||
data={
|
||||
"anything_else": "Bacon ipsum dolor amet fatback strip steak pastrami"
|
||||
"shankle, drumstick doner chicken landjaeger turkey andouille."
|
||||
|
|
|
@ -1229,6 +1229,71 @@ class TestPortfolioInvitations(TestCase):
|
|||
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):
|
||||
"""Test actions that occur on user login,
|
||||
test class method that controls how users get validated."""
|
||||
|
|
|
@ -12,7 +12,7 @@ from registrar.models import (
|
|||
)
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
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 django.contrib.sessions.middleware import SessionMiddleware
|
||||
|
||||
|
@ -101,8 +101,8 @@ class TestPortfolio(WebTest):
|
|||
self.assertNotContains(portfolio_page, self.portfolio.organization_name)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_middleware_redirects_to_portfolio_organization_page(self):
|
||||
"""Test that user with a portfolio and VIEW_PORTFOLIO is redirected to portfolio organization page"""
|
||||
def test_middleware_redirects_to_portfolio_no_domains_page(self):
|
||||
"""Test that user with a portfolio and VIEW_PORTFOLIO is redirected to the no domains page"""
|
||||
self.app.set_user(self.user.username)
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
|
@ -115,7 +115,8 @@ class TestPortfolio(WebTest):
|
|||
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.assertContains(portfolio_page, "<h1>Organization</h1>")
|
||||
self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
|
||||
self.assertContains(portfolio_page, "You aren’t managing any domains")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_middleware_redirects_to_portfolio_domains_page(self):
|
||||
|
@ -229,8 +230,8 @@ class TestPortfolio(WebTest):
|
|||
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"""
|
||||
def test_accessible_pages_when_user_does_not_have_permission(self):
|
||||
"""Tests which pages are accessible when user does not have portfolio permissions"""
|
||||
self.app.set_user(self.user.username)
|
||||
portfolio_additional_permissions = [
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
|
@ -257,16 +258,29 @@ class TestPortfolio(WebTest):
|
|||
portfolio_permission.save()
|
||||
portfolio_permission.refresh_from_db()
|
||||
|
||||
# Members should be redirected to the readonly domains page
|
||||
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, "<h1>Organization</h1>")
|
||||
self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
|
||||
self.assertContains(portfolio_page, "You aren’t managing any domains")
|
||||
self.assertNotContains(portfolio_page, reverse("domains"))
|
||||
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
|
||||
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"""
|
||||
self.app.set_user(self.user.username)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
|
@ -290,14 +304,27 @@ class TestPortfolio(WebTest):
|
|||
portfolio_permission.save()
|
||||
portfolio_permission.refresh_from_db()
|
||||
|
||||
# Members should be redirected to the readonly domains page
|
||||
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, "<h1>Organization</h1>")
|
||||
self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
|
||||
self.assertContains(portfolio_page, "You aren’t managing any domains")
|
||||
self.assertNotContains(portfolio_page, reverse("domains"))
|
||||
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
|
||||
def test_portfolio_org_name(self):
|
||||
"""Can load portfolio's org name page."""
|
||||
|
@ -465,3 +492,48 @@ class TestPortfolio(WebTest):
|
|||
assert 'portfolio' in session, "Portfolio session variable should exist."
|
||||
# Check the value of the 'portfolio' session variable
|
||||
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 aren’t 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()
|
||||
|
|
|
@ -1017,20 +1017,27 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
type_page = intro_result.follow()
|
||||
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["generic_org_type-generic_org_type"] = "federal"
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
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)
|
||||
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)
|
||||
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(
|
||||
len(new_page.html.find_all("a", href="/request/organization_federal/")),
|
||||
0,
|
||||
|
@ -2528,9 +2535,22 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
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)
|
||||
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")
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -2628,9 +2648,15 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
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
|
||||
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.assertNotContains(dotgov_page, "medicare.gov")
|
||||
|
||||
|
@ -2984,6 +3010,9 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
|
|||
"""Test when all fields in the domain request are filled."""
|
||||
|
||||
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/")
|
||||
# django-webtest does not handle cookie-based sessions well because it keeps
|
||||
|
|
|
@ -217,7 +217,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
if current_url == self.EDIT_URL_NAME and "id" in kwargs:
|
||||
del self.storage
|
||||
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
|
||||
# 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:
|
||||
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()
|
||||
self.steps.current = current_url
|
||||
context["forms"] = self.get_forms()
|
||||
|
@ -341,6 +343,17 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
"""Helper for get_context_data
|
||||
|
||||
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 = {
|
||||
"generic_org_type": self.domain_request.generic_org_type 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
|
||||
),
|
||||
"additional_details": (
|
||||
(self.domain_request.anything_else is not None and self.domain_request.has_cisa_representative)
|
||||
or self.domain_request.is_policy_acknowledged is not None
|
||||
# Additional details is complete as long as "has anything else" and "has cisa rep" are 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,
|
||||
"review": self.domain_request.is_policy_acknowledged is not None,
|
||||
|
@ -626,8 +642,8 @@ class AdditionalDetails(DomainRequestWizard):
|
|||
forms = [
|
||||
forms.CisaRepresentativeYesNoForm,
|
||||
forms.CisaRepresentativeForm,
|
||||
forms.AdditionalDetailsYesNoForm,
|
||||
forms.AdditionalDetailsForm,
|
||||
forms.AnythingElseYesNoForm,
|
||||
forms.AnythingElseForm,
|
||||
]
|
||||
|
||||
def is_valid(self, forms: list) -> bool:
|
||||
|
|
|
@ -4,11 +4,14 @@ from django.shortcuts import render
|
|||
from django.urls import reverse
|
||||
from django.contrib import messages
|
||||
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 (
|
||||
PortfolioDomainRequestsPermissionView,
|
||||
PortfolioDomainsPermissionView,
|
||||
PortfolioBasePermissionView,
|
||||
NoPortfolioDomainsPermissionView,
|
||||
)
|
||||
from django.views.generic import View
|
||||
from django.views.generic.edit import FormMixin
|
||||
|
@ -38,6 +41,35 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
|
|||
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):
|
||||
"""
|
||||
View to handle displaying and updating the portfolio's organization details.
|
||||
|
|
|
@ -432,10 +432,11 @@ class PortfolioDomainsPermission(PortfolioBasePermission):
|
|||
The user is in self.request.user and the portfolio can be looked
|
||||
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 self.request.user.is_org_user(self.request)
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioDomainRequestsPermission(PortfolioBasePermission):
|
||||
|
@ -448,7 +449,8 @@ class PortfolioDomainRequestsPermission(PortfolioBasePermission):
|
|||
The user is in self.request.user and the portfolio can be looked
|
||||
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 self.request.user.is_org_user(self.request)
|
||||
return super().has_permission()
|
||||
|
|
|
@ -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):
|
||||
"""Abstract base view for portfolio domain request views that enforces permissions.
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue