Merge branch 'main' into bob/2378-portfolio-senior-official

This commit is contained in:
zandercymatics 2024-08-09 13:11:56 -06:00
commit 3e992f9ec8
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
14 changed files with 188 additions and 43 deletions

View file

@ -30,7 +30,19 @@ You should end up with `40_some_migration_from_main`, `41_local_migration`
Alternatively, assuming that the conflicting migrations are not dependent on each other, you can manually edit the migration file such that your new migration is incremented by one (file name, and definition inside the file) but this approach is not recommended.
### Scenario 2: Conflicting migrations on sandbox
### Scenario 2: Conflicting migrations on sandbox (can be fixed with GH workflow)
A 500 error on a sanbox after a fresh push usually indicates a migration issue.
Most of the time, these migration issues can easily be fixed by simply running the
"reset-db" workflow in Github.
For the workflow, select the following inputs before running it;
"Use workflow from": Branch-main
"Which environment should we flush and re-load data for?" <YOUR_TARGET_SANDBOX>
This is not a cure-all since it simply flushes and re-runs migrations against your sandbox.
If running this workflow does not solve your issue, proceed examining the scenarios below.
### Scenario 3: Conflicting migrations on sandbox (cannot be fixed with GH workflow)
This occurs when the logs return the following:
>Conflicting migrations detected; multiple leaf nodes in the migration graph: (0040_example, 0041_example in base).

View file

@ -4,6 +4,7 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import Q
from registrar.models.domain_information import DomainInformation
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@ -265,6 +266,10 @@ class User(AbstractUser):
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
def has_view_all_domains_permission(self):
"""Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
@classmethod
def needs_identity_verification(cls, email, uuid):
"""A method used by our oidc classes to test whether a user needs email/uuid verification
@ -406,3 +411,10 @@ class User(AbstractUser):
def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature")
return has_organization_feature_flag and self.has_base_portfolio_permission()
def get_user_domain_ids(self, request):
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""
if self.is_org_user(request) and self.has_view_all_domains_permission():
return DomainInformation.objects.filter(portfolio=self.portfolio).values_list("domain_id", flat=True)
else:
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)

View file

@ -4,7 +4,6 @@ import time
import logging
from urllib.parse import urlparse, urlunparse, urlencode
logger = logging.getLogger(__name__)
@ -173,10 +172,6 @@ class CreateOrUpdateOrganizationTypeHelper:
self.instance.is_election_board = None
self.instance.organization_type = generic_org_type
else:
# This can only happen with manual data tinkering, which causes these to be out of sync.
if self.instance.is_election_board is None:
self.instance.is_election_board = False
if self.instance.is_election_board:
self.instance.organization_type = self.generic_org_to_org_map[generic_org_type]
else:
@ -219,12 +214,15 @@ class CreateOrUpdateOrganizationTypeHelper:
self.instance.is_election_board = None
self.instance.generic_org_type = None
def _validate_new_instance(self):
def _validate_new_instance(self) -> bool:
"""
Validates whether a new instance of DomainRequest or DomainInformation can proceed with the update
based on the consistency between organization_type, generic_org_type, and is_election_board.
Returns a boolean determining if execution should proceed or not.
Raises:
ValueError if there is a mismatch between organization_type, generic_org_type, and is_election_board
"""
# We conditionally accept both of these values to exist simultaneously, as long as
@ -242,13 +240,20 @@ class CreateOrUpdateOrganizationTypeHelper:
is_election_type = "_election" in organization_type
can_have_election_board = organization_type in self.generic_org_to_org_map
election_board_mismatch = (is_election_type != self.instance.is_election_board) and can_have_election_board
election_board_mismatch = (
is_election_type and not self.instance.is_election_board and can_have_election_board
)
org_type_mismatch = mapped_org_type is not None and (generic_org_type != mapped_org_type)
if election_board_mismatch or org_type_mismatch:
message = (
"Cannot add organization_type and generic_org_type simultaneously "
"when generic_org_type, is_election_board, and organization_type values do not match."
"Cannot add organization_type and generic_org_type simultaneously when"
"generic_org_type ({}), is_election_board ({}), and organization_type ({}) don't match.".format(
generic_org_type, self.instance.is_election_board, organization_type
)
)
message = "Mismatch on election board, {}".format(message) if election_board_mismatch else message
message = "Mistmatch on org type, {}".format(message) if org_type_mismatch else message
logger.error("_validate_new_instance: %s", message)
raise ValueError(message)
return True

View file

@ -29,7 +29,7 @@
</a>
</p>
{% include "includes/domains_table.html" %}
{% include "includes/domains_table.html" with user_domain_count=user_domain_count %}
{% include "includes/domain_requests_table.html" %}
</div>

View file

@ -37,6 +37,7 @@
</form>
</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 %}">
<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">
@ -46,6 +47,7 @@
</a>
</section>
</div>
{% endif %}
</div>
{% if has_domains_portfolio_permission %}
<div class="display-flex flex-align-center">

View file

@ -56,12 +56,13 @@
{% 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 %}
{% public_site_url "help/account-management/#email-address" as login_help_url %}
{% 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 %}
{% with sublabel_text="We recommend using a Login.gov account that's only connected to your work email address. If you need to change your email, you'll need to make a change to your Login.gov account. Get help with updating your email address." %}
{% with link_text="Get help with updating your email address" target_blank=True do_not_show_max_chars=True %}
{% input_with_errors form.email %}
{% endwith %}
{% endwith %}

View file

@ -30,11 +30,11 @@
{% input_with_errors form.title %}
{% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %}
{% public_site_url "help/account-management/#email-address" as login_help_url %}
{% 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" %}
{% with sublabel_text="We recommend using a Login.gov account that's only connected to your work email address. If you need to change your email, you'll need to make a change to your Login.gov account. Get help with updating your email address." %}
{% with link_text="Get help with updating your email address" %}
{% with target_blank=True %}
{% with do_not_show_max_chars=True %}
{% input_with_errors form.email %}

View file

@ -6,5 +6,5 @@
{% block portfolio_content %}
<h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio %}
{% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %}
{% endblock %}

View file

@ -1495,11 +1495,28 @@ class TestDomainRequestCustomSave(TestCase):
self.assertEqual(domain_request.is_election_board, False)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
# Try reverting setting an invalid value for election board (should revert to False)
@less_console_noise_decorator
def test_existing_instance_updates_election_board_to_none(self):
"""Test create_or_update_organization_type for an existing instance, first to True and then to None.
Start our with is_election_board as none to simulate a situation where the request was started, but
only completed to the point of filling out the generic_org_type."""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=None,
)
domain_request.is_election_board = True
domain_request.save()
self.assertEqual(domain_request.is_election_board, True)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
# Try reverting the election board value.
domain_request.is_election_board = None
domain_request.save()
self.assertEqual(domain_request.is_election_board, False)
self.assertEqual(domain_request.is_election_board, None)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
@less_console_noise_decorator
@ -1654,11 +1671,30 @@ class TestDomainInformationCustomSave(TestCase):
self.assertEqual(domain_information.is_election_board, False)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
# Try reverting setting an invalid value for election board (should revert to False)
domain_information.is_election_board = None
@less_console_noise_decorator
def test_existing_instance_update_election_board_to_none(self):
"""Test create_or_update_organization_type for an existing instance, first to True and then to None.
Start our with is_election_board as none to simulate a situation where the request was started, but
only completed to the point of filling out the generic_org_type."""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=None,
)
domain_information = DomainInformation.create_from_da(domain_request)
domain_information.is_election_board = True
domain_information.save()
self.assertEqual(domain_information.is_election_board, False)
self.assertEqual(domain_information.is_election_board, True)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
# Try reverting the election board value
domain_information.is_election_board = None
domain_information.save()
domain_information.refresh_from_db()
self.assertEqual(domain_information.is_election_board, None)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
@less_console_noise_decorator
@ -1858,8 +1894,7 @@ class TestDomainRequestIncomplete(TestCase):
self.assertTrue(self.domain_request._is_state_or_territory_complete())
self.domain_request.is_election_board = None
self.domain_request.save()
# is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election
self.assertTrue(self.domain_request._is_state_or_territory_complete())
self.assertFalse(self.domain_request._is_state_or_territory_complete())
@less_console_noise_decorator
def test_is_tribal_complete(self):
@ -1868,10 +1903,11 @@ class TestDomainRequestIncomplete(TestCase):
self.domain_request.is_election_board = False
self.domain_request.save()
self.assertTrue(self.domain_request._is_tribal_complete())
self.domain_request.tribe_name = None
self.domain_request.is_election_board = None
self.domain_request.save()
# is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election
self.assertFalse(self.domain_request._is_tribal_complete())
self.domain_request.tribe_name = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_tribal_complete())
@less_console_noise_decorator
@ -1882,8 +1918,7 @@ class TestDomainRequestIncomplete(TestCase):
self.assertTrue(self.domain_request._is_county_complete())
self.domain_request.is_election_board = None
self.domain_request.save()
# is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election
self.assertTrue(self.domain_request._is_county_complete())
self.assertFalse(self.domain_request._is_county_complete())
@less_console_noise_decorator
def test_is_city_complete(self):
@ -1893,8 +1928,7 @@ class TestDomainRequestIncomplete(TestCase):
self.assertTrue(self.domain_request._is_city_complete())
self.domain_request.is_election_board = None
self.domain_request.save()
# is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election
self.assertTrue(self.domain_request._is_city_complete())
self.assertFalse(self.domain_request._is_city_complete())
@less_console_noise_decorator
def test_is_special_district_complete(self):
@ -1903,10 +1937,11 @@ class TestDomainRequestIncomplete(TestCase):
self.domain_request.is_election_board = False
self.domain_request.save()
self.assertTrue(self.domain_request._is_special_district_complete())
self.domain_request.about_your_organization = None
self.domain_request.is_election_board = None
self.domain_request.save()
# is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election
self.assertFalse(self.domain_request._is_special_district_complete())
self.domain_request.about_your_organization = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_special_district_complete())
@less_console_noise_decorator

View file

@ -6,6 +6,8 @@ from registrar.models import (
Domain,
UserDomainRole,
)
from registrar.models import Portfolio
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.csv_export import (
DomainDataFull,
DomainDataType,
@ -32,6 +34,7 @@ from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # typ
from django.utils import timezone
from api.tests.common import less_console_noise_decorator
from .common import MockDbForSharedTests, MockDbForIndividualTests, MockEppLib, less_console_noise, get_time_aware_date
from waffle.testutils import override_flag
class CsvReportsTest(MockDbForSharedTests):
@ -311,6 +314,80 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
self.maxDiff = None
self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_domain_data_type_user_with_portfolio(self):
"""Tests DomainDataTypeUser export with portfolio permissions"""
# Create a portfolio and assign it to the user
portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
self.user.portfolio = portfolio
self.user.save()
UserDomainRole.objects.create(user=self.user, domain=self.domain_2)
UserDomainRole.objects.filter(user=self.user, domain=self.domain_1).delete()
UserDomainRole.objects.filter(user=self.user, domain=self.domain_3).delete()
# Add portfolios to the first and third domains
self.domain_1.domain_info.portfolio = portfolio
self.domain_3.domain_info.portfolio = portfolio
self.domain_1.domain_info.save()
self.domain_3.domain_info.save()
# Set up user permissions
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save()
self.user.refresh_from_db()
# Create a request object
factory = RequestFactory()
request = factory.get("/")
request.user = self.user
# Get the csv content
csv_content = self._run_domain_data_type_user_export(request)
# We expect only domains associated with the user's portfolio
self.assertIn(self.domain_1.name, csv_content)
self.assertIn(self.domain_3.name, csv_content)
self.assertNotIn(self.domain_2.name, csv_content)
# Test the output for readonly admin
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY]
self.user.save()
self.assertIn(self.domain_1.name, csv_content)
self.assertIn(self.domain_3.name, csv_content)
self.assertNotIn(self.domain_2.name, csv_content)
# Get the csv content
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
self.user.save()
csv_content = self._run_domain_data_type_user_export(request)
self.assertNotIn(self.domain_1.name, csv_content)
self.assertNotIn(self.domain_3.name, csv_content)
self.assertIn(self.domain_2.name, csv_content)
self.domain_1.delete()
self.domain_2.delete()
self.domain_3.delete()
portfolio.delete()
def _run_domain_data_type_user_export(self, request):
"""Helper function to run the export_data_to_csv function on DomainDataTypeUser"""
# Create a CSV file in memory
csv_file = StringIO()
# Call the export functions
DomainDataTypeUser.export_data_to_csv(csv_file, request=request)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
# Read the content into a variable
csv_content = csv_file.read()
return csv_content
@less_console_noise_decorator
def test_domain_data_full(self):
"""Shows security contacts, filtered by state"""

View file

@ -578,10 +578,9 @@ class DomainDataTypeUser(DomainDataType):
if request is None or not hasattr(request, "user") or not request.user:
# Return nothing
return Q(id__in=[])
user_domain_roles = UserDomainRole.objects.filter(user=request.user)
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
return Q(domain__id__in=domain_ids)
else:
# Get all domains the user is associated with
return Q(domain__id__in=request.user.get_user_domain_ids(request))
class DomainDataFull(DomainExport):

View file

@ -1,13 +1,11 @@
import logging
from django.http import JsonResponse
from django.core.paginator import Paginator
from registrar.models import UserDomainRole, Domain
from registrar.models import UserDomainRole, Domain, DomainInformation
from django.contrib.auth.decorators import login_required
from django.urls import reverse
from django.db.models import Q
from registrar.models.domain_information import DomainInformation
logger = logging.getLogger(__name__)

View file

@ -5,8 +5,9 @@ def index(request):
"""This page is available to anyone without logging in."""
context = {}
if request.user.is_authenticated:
if request and request.user and request.user.is_authenticated:
# This controls the creation of a new domain request in the wizard
request.session["new_request"] = True
context["user_domain_count"] = request.user.get_user_domain_ids(request).count()
return render(request, "home.html", context)

View file

@ -22,7 +22,10 @@ class PortfolioDomainsView(PortfolioDomainsPermissionView, View):
template_name = "portfolio_domains.html"
def get(self, request):
return render(request, "portfolio_domains.html")
context = {}
if self.request and self.request.user and self.request.user.is_authenticated:
context["user_domain_count"] = self.request.user.get_user_domain_ids(request).count()
return render(request, "portfolio_domains.html", context)
class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):