diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 5914eb179..c261e7a06 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -838,3 +838,45 @@ Example: `cf ssh getgov-za` ### Running locally ```docker-compose exec app ./manage.py populate_domain_request_dates``` + +## Create federal portfolio +This script takes the name of a `FederalAgency` (like 'AMTRAK') and does the following: +1. Creates the portfolio record based off of data on the federal agency object itself +2. Creates suborganizations from existing DomainInformation records +3. Associates the SeniorOfficial record (if it exists) +4. Adds this portfolio to DomainInformation / DomainRequests or both + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: SSH into your environment +```cf ssh getgov-{space}``` + +Example: `cf ssh getgov-za` + +#### Step 3: Create a shell instance +```/tmp/lifecycle/shell``` + +#### Step 4: Upload your csv to the desired sandbox +[Follow these steps](#use-scp-to-transfer-data-to-sandboxes) to upload the federal_cio csv to a sandbox of your choice. + +#### Step 5: Running the script +```./manage.py create_federal_portfolio "{federal_agency_name}" --parse_requests --parse_domains``` + +Example: `./manage.py create_federal_portfolio "AMTRAK" --parse_requests --parse_domains` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py create_federal_portfolio "{federal_agency_name}" --parse_requests --parse_domains``` + +##### Parameters +| | Parameter | Description | +|:-:|:-------------------------- |:-------------------------------------------------------------------------------------------| +| 1 | **federal_agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". | +| 2 | **parse_requests** | Optional. If True, then the created portfolio is added to all related DomainRequests. | +| 3 | **parse_domains** | Optional. If True, then the created portfolio is added to all related Domains. | + +Note: While you can specify both at the same time, you must specify either --parse_requests or --parse_domains. You cannot run the script without defining one or the other. diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py index a5b432247..4ca6ba17f 100644 --- a/src/registrar/management/commands/create_federal_portfolio.py +++ b/src/registrar/management/commands/create_federal_portfolio.py @@ -72,8 +72,7 @@ class Command(BaseCommand): portfolio_args["senior_official"] = federal_agency.so_federal_agency.first() portfolio, created = Portfolio.objects.get_or_create( - organization_name=portfolio_args.get("organization_name"), - defaults=portfolio_args + organization_name=portfolio_args.get("organization_name"), defaults=portfolio_args ) if created: @@ -88,6 +87,15 @@ class Command(BaseCommand): prompt_title="Do you wish to modify this record?", ) if proceed: + + # Don't override the creator and notes fields + if portfolio.creator: + portfolio_args.pop("creator") + + if portfolio.notes: + portfolio_args.pop("notes") + + # Update everything else for key, value in portfolio_args.items(): setattr(portfolio, key, value) portfolio.save() @@ -98,11 +106,15 @@ class Command(BaseCommand): def create_suborganizations(self, portfolio: Portfolio, federal_agency: FederalAgency): """Create Suborganizations tied to the given portfolio based on DomainInformation objects""" - valid_agencies = DomainInformation.objects.filter(federal_agency=federal_agency, organization_name__isnull=False) + valid_agencies = DomainInformation.objects.filter( + federal_agency=federal_agency, organization_name__isnull=False + ) org_names = set(valid_agencies.values_list("organization_name", flat=True)) if not org_names: - TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, f"No suborganizations found for {federal_agency}") + TerminalHelper.colorful_logger( + logger.warning, TerminalColors.YELLOW, f"No suborganizations found for {federal_agency}" + ) return # Check if we need to update any existing suborgs first. This step is optional. @@ -116,13 +128,17 @@ class Command(BaseCommand): if name.lower() == portfolio.organization_name.lower(): # If the suborg name is a portfolio name that currently exists, thats not a suborg - thats the portfolio itself! # In this case, we can use this as an opportunity to update address information. - self._update_portfolio_location_details(portfolio, valid_agencies.filter(organization_name=name).first()) + self._update_portfolio_location_details( + portfolio, valid_agencies.filter(organization_name=name).first() + ) else: new_suborgs.append(Suborganization(name=name, portfolio=portfolio)) if new_suborgs: Suborganization.objects.bulk_create(new_suborgs) - TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, f"Added {len(new_suborgs)} suborganizations") + TerminalHelper.colorful_logger( + logger.info, TerminalColors.OKGREEN, f"Added {len(new_suborgs)} suborganizations" + ) else: TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, "No suborganizations added") diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index bcd45f103..78cd1aafb 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -911,6 +911,8 @@ def completed_domain_request( # noqa federal_type=None, action_needed_reason=None, portfolio=None, + organization_name=None, + city=None, ): """A completed domain request.""" if not user: @@ -954,7 +956,7 @@ def completed_domain_request( # noqa federal_type="executive", purpose="Purpose of the site", is_policy_acknowledged=True, - organization_name="Testorg", + organization_name=organization_name if organization_name else "Testorg", address_line1="address 1", address_line2="address 2", state_territory="NY", @@ -984,6 +986,9 @@ def completed_domain_request( # noqa if portfolio: domain_request_kwargs["portfolio"] = portfolio + if city: + domain_request_kwargs["city"] = city + domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs) if has_other_contacts: diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py index f9c544bfe..77822a022 100644 --- a/src/registrar/tests/test_management_scripts.py +++ b/src/registrar/tests/test_management_scripts.py @@ -1,4 +1,5 @@ import copy +import boto3_mocking # type: ignore from datetime import date, datetime, time from django.core.management import call_command from django.test import TestCase, override_settings @@ -8,6 +9,7 @@ from django.utils import timezone from django.utils.module_loading import import_string import logging import pyzipper +from django.core.management.base import CommandError from registrar.management.commands.clean_tables import Command as CleanTablesCommand from registrar.management.commands.export_tables import Command as ExportTablesCommand from registrar.models import ( @@ -23,14 +25,17 @@ from registrar.models import ( VerifiedByStaff, PublicContact, FederalAgency, + Portfolio, + Suborganization, ) import tablib from unittest.mock import patch, call, MagicMock, mock_open from epplibwrapper import commands, common -from .common import MockEppLib, less_console_noise, completed_domain_request +from .common import MockEppLib, less_console_noise, completed_domain_request, MockSESClient from api.tests.common import less_console_noise_decorator + logger = logging.getLogger(__name__) @@ -1411,23 +1416,125 @@ class TestPopulateFederalAgencyInitialsAndFceb(TestCase): class TestCreateFederalPortfolio(TestCase): - def setUp(self): - self.csv_path = "registrar/tests/data/fake_federal_cio.csv" - # Create test FederalAgency objects - self.agency1, _ = FederalAgency.objects.get_or_create(agency="American Battle Monuments Commission") - self.agency2, _ = FederalAgency.objects.get_or_create(agency="Advisory Council on Historic Preservation") - self.agency3, _ = FederalAgency.objects.get_or_create(agency="AMTRAK") - self.agency4, _ = FederalAgency.objects.get_or_create(agency="John F. Kennedy Center for Performing Arts") + @less_console_noise_decorator + def setUp(self): + self.mock_client = MockSESClient() + self.user = User.objects.create(username="testuser") + self.federal_agency = FederalAgency.objects.create(agency="Test Federal Agency") + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + self.domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.federal_agency, + user=self.user, + city="WrongCity", + ) + self.domain_request.approve() + self.domain_info = DomainInformation.objects.filter(domain_request=self.domain_request).get() + + self.domain_request_2 = completed_domain_request( + name="sock@igorville.org", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.federal_agency, + user=self.user, + organization_name="Test Federal Agency", + city="Block", + ) + self.domain_request_2.approve() + self.domain_info_2 = DomainInformation.objects.filter(domain_request=self.domain_request_2).get() def tearDown(self): - SeniorOfficial.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + Suborganization.objects.all().delete() + Portfolio.objects.all().delete() FederalAgency.objects.all().delete() - - # == create_or_modify_portfolio tests == # - # == create_suborganizations tests == # - # == handle_portfolio_requests tests == # - # == handle_portfolio_domains tests == # - # test for parse_requests - # test for parse_domains - # test for both + User.objects.all().delete() + + @less_console_noise_decorator + def run_create_federal_portfolio(self, agency_name, parse_requests=False, parse_domains=False): + with patch( + "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", + return_value=True, + ): + call_command( + "create_federal_portfolio", agency_name, parse_requests=parse_requests, parse_domains=parse_domains + ) + + def test_create_or_modify_portfolio(self): + self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True) + + portfolio = Portfolio.objects.get(federal_agency=self.federal_agency) + self.assertEqual(portfolio.organization_name, self.federal_agency.agency) + self.assertEqual(portfolio.organization_type, DomainRequest.OrganizationChoices.FEDERAL) + self.assertEqual(portfolio.creator, User.get_default_user()) + self.assertEqual(portfolio.notes, "Auto-generated record") + + # Test the suborgs + suborganizations = Suborganization.objects.filter(portfolio__federal_agency=self.federal_agency) + self.assertEqual(suborganizations.count(), 1) + self.assertEqual(suborganizations.first().name, "Testorg") + + # Test other address information + self.assertEqual(portfolio.address_line1, "address 1") + self.assertEqual(portfolio.city, "Block") + self.assertEqual(portfolio.state_territory, "NY") + self.assertEqual(portfolio.zipcode, "10002") + + def test_handle_portfolio_requests(self): + self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True) + + self.domain_request.refresh_from_db() + self.assertIsNotNone(self.domain_request.portfolio) + self.assertEqual(self.domain_request.portfolio.federal_agency, self.federal_agency) + + def test_handle_portfolio_domains(self): + self.run_create_federal_portfolio("Test Federal Agency", parse_domains=True) + + self.domain_info.refresh_from_db() + self.assertIsNotNone(self.domain_info.portfolio) + self.assertEqual(self.domain_info.portfolio.federal_agency, self.federal_agency) + + def test_handle_parse_both(self): + self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True, parse_domains=True) + + self.domain_request.refresh_from_db() + self.domain_info.refresh_from_db() + self.assertIsNotNone(self.domain_request.portfolio) + self.assertIsNotNone(self.domain_info.portfolio) + self.assertEqual(self.domain_request.portfolio, self.domain_info.portfolio) + + def test_command_error_no_parse_options(self): + with self.assertRaisesRegex( + CommandError, "You must specify at least one of --parse_requests or --parse_domains." + ): + self.run_create_federal_portfolio("Test Federal Agency") + + def test_command_error_agency_not_found(self): + expected_message = ( + "Cannot find the federal agency 'Non-existent Agency' in our database. " + "The value you enter for `agency_name` must be prepopulated in the FederalAgency table before proceeding." + ) + with self.assertRaisesRegex(ValueError, expected_message): + self.run_create_federal_portfolio("Non-existent Agency", parse_requests=True) + + def test_update_existing_portfolio(self): + # Create an existing portfolio + existing_portfolio = Portfolio.objects.create( + federal_agency=self.federal_agency, + organization_name="Test Federal Agency", + organization_type=DomainRequest.OrganizationChoices.CITY, + creator=self.user, + notes="Old notes", + ) + + self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True) + + existing_portfolio.refresh_from_db() + self.assertEqual(existing_portfolio.organization_name, self.federal_agency.agency) + self.assertEqual(existing_portfolio.organization_type, DomainRequest.OrganizationChoices.FEDERAL) + # Notes and creator should be untouched + self.assertEqual(existing_portfolio.notes, "Old notes") + self.assertEqual(existing_portfolio.creator, self.user)