diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 160b906ab..8a691c7fa 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2552,6 +2552,34 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): super().save_model(request, obj, form, change) +class PortfolioAdmin(ListHeaderAdmin): + # NOTE: these are just placeholders. Not part of ACs (haven't been defined yet). Update in future tickets. + list_display = ("organization_name", "federal_agency", "creator") + search_fields = ["organization_name"] + search_help_text = "Search by organization name." + # readonly_fields = [ + # "requestor", + # ] + + def save_model(self, request, obj, form, change): + + if obj.creator is not None: + # ---- update creator ---- + # Set the creator field to the current admin user + obj.creator = request.user if request.user.is_authenticated else None + + # ---- update organization name ---- + # org name will be the same as federal agency, if it is federal, + # otherwise it will be the actual org name. If nothing is entered for + # org name and it is a federal organization, have this field fill with + # the federal agency text name. + is_federal = obj.organization_type == DomainRequest.OrganizationChoices.FEDERAL + if is_federal and obj.organization_name is None: + obj.organization_name = obj.federal_agency.agency + + super().save_model(request, obj, form, change) + + class FederalAgencyAdmin(ListHeaderAdmin): list_display = ["agency"] search_fields = ["agency"] @@ -2622,6 +2650,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin) admin.site.register(models.DomainRequest, DomainRequestAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin) admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) +admin.site.register(models.Portfolio, PortfolioAdmin) # Register our custom waffle implementations admin.site.register(models.WaffleFlag, WaffleFlagAdmin) diff --git a/src/registrar/migrations/0103_portfolio_domaininformation_portfolio_and_more.py b/src/registrar/migrations/0103_portfolio_domaininformation_portfolio_and_more.py new file mode 100644 index 000000000..ac7a69074 --- /dev/null +++ b/src/registrar/migrations/0103_portfolio_domaininformation_portfolio_and_more.py @@ -0,0 +1,174 @@ +# Generated by Django 4.2.10 on 2024-06-18 17:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import registrar.models.federal_agency + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0102_domain_dsdata_last_change"), + ] + + operations = [ + migrations.CreateModel( + name="Portfolio", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("notes", models.TextField(blank=True, null=True)), + ( + "organization_type", + models.CharField( + blank=True, + choices=[ + ("federal", "Federal"), + ("interstate", "Interstate"), + ("state_or_territory", "State or territory"), + ("tribal", "Tribal"), + ("county", "County"), + ("city", "City"), + ("special_district", "Special district"), + ("school_district", "School district"), + ], + help_text="Type of organization", + max_length=255, + null=True, + ), + ), + ("organization_name", models.CharField(blank=True, null=True)), + ("address_line1", models.CharField(blank=True, null=True, verbose_name="address line 1")), + ("address_line2", models.CharField(blank=True, null=True, verbose_name="address line 2")), + ("city", models.CharField(blank=True, null=True)), + ( + "state_territory", + models.CharField( + blank=True, + choices=[ + ("AL", "Alabama (AL)"), + ("AK", "Alaska (AK)"), + ("AS", "American Samoa (AS)"), + ("AZ", "Arizona (AZ)"), + ("AR", "Arkansas (AR)"), + ("CA", "California (CA)"), + ("CO", "Colorado (CO)"), + ("CT", "Connecticut (CT)"), + ("DE", "Delaware (DE)"), + ("DC", "District of Columbia (DC)"), + ("FL", "Florida (FL)"), + ("GA", "Georgia (GA)"), + ("GU", "Guam (GU)"), + ("HI", "Hawaii (HI)"), + ("ID", "Idaho (ID)"), + ("IL", "Illinois (IL)"), + ("IN", "Indiana (IN)"), + ("IA", "Iowa (IA)"), + ("KS", "Kansas (KS)"), + ("KY", "Kentucky (KY)"), + ("LA", "Louisiana (LA)"), + ("ME", "Maine (ME)"), + ("MD", "Maryland (MD)"), + ("MA", "Massachusetts (MA)"), + ("MI", "Michigan (MI)"), + ("MN", "Minnesota (MN)"), + ("MS", "Mississippi (MS)"), + ("MO", "Missouri (MO)"), + ("MT", "Montana (MT)"), + ("NE", "Nebraska (NE)"), + ("NV", "Nevada (NV)"), + ("NH", "New Hampshire (NH)"), + ("NJ", "New Jersey (NJ)"), + ("NM", "New Mexico (NM)"), + ("NY", "New York (NY)"), + ("NC", "North Carolina (NC)"), + ("ND", "North Dakota (ND)"), + ("MP", "Northern Mariana Islands (MP)"), + ("OH", "Ohio (OH)"), + ("OK", "Oklahoma (OK)"), + ("OR", "Oregon (OR)"), + ("PA", "Pennsylvania (PA)"), + ("PR", "Puerto Rico (PR)"), + ("RI", "Rhode Island (RI)"), + ("SC", "South Carolina (SC)"), + ("SD", "South Dakota (SD)"), + ("TN", "Tennessee (TN)"), + ("TX", "Texas (TX)"), + ("UM", "United States Minor Outlying Islands (UM)"), + ("UT", "Utah (UT)"), + ("VT", "Vermont (VT)"), + ("VI", "Virgin Islands (VI)"), + ("VA", "Virginia (VA)"), + ("WA", "Washington (WA)"), + ("WV", "West Virginia (WV)"), + ("WI", "Wisconsin (WI)"), + ("WY", "Wyoming (WY)"), + ("AA", "Armed Forces Americas (AA)"), + ("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"), + ("AP", "Armed Forces Pacific (AP)"), + ], + max_length=2, + null=True, + verbose_name="state / territory", + ), + ), + ("zipcode", models.CharField(blank=True, max_length=10, null=True, verbose_name="zip code")), + ( + "urbanization", + models.CharField( + blank=True, help_text="Required for Puerto Rico only", null=True, verbose_name="urbanization" + ), + ), + ( + "security_contact_email", + models.EmailField(blank=True, max_length=320, null=True, verbose_name="security contact e-mail"), + ), + ( + "creator", + models.ForeignKey( + help_text="Associated user", + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "federal_agency", + models.ForeignKey( + default=registrar.models.federal_agency.FederalAgency.get_non_federal_agency, + help_text="Associated federal agency", + on_delete=django.db.models.deletion.PROTECT, + to="registrar.federalagency", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="domaininformation", + name="portfolio", + field=models.ForeignKey( + blank=True, + help_text="Portfolio associated with this domain", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="DomainRequest_portfolio", + to="registrar.portfolio", + ), + ), + migrations.AddField( + model_name="domainrequest", + name="portfolio", + field=models.ForeignKey( + blank=True, + help_text="Portfolio associated with this domain request", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="DomainInformation_portfolio", + to="registrar.portfolio", + ), + ), + ] diff --git a/src/registrar/migrations/0104_create_groups_v13.py b/src/registrar/migrations/0104_create_groups_v13.py new file mode 100644 index 000000000..0ce3bafa5 --- /dev/null +++ b/src/registrar/migrations/0104_create_groups_v13.py @@ -0,0 +1,37 @@ +# This migration creates the create_full_access_group and create_cisa_analyst_group groups +# It is dependent on 0079 (which populates federal agencies) +# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS +# in the user_group model then: +# [NOT RECOMMENDED] +# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions +# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups +# step 3: fake run the latest migration in the migrations list +# [RECOMMENDED] +# Alternatively: +# step 1: duplicate the migration that loads data +# step 2: docker-compose exec app ./manage.py migrate + +from django.db import migrations +from registrar.models import UserGroup +from typing import Any + + +# For linting: RunPython expects a function reference, +# so let's give it one +def create_groups(apps, schema_editor) -> Any: + UserGroup.create_cisa_analyst_group(apps, schema_editor) + UserGroup.create_full_access_group(apps, schema_editor) + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0103_portfolio_domaininformation_portfolio_and_more"), + ] + + operations = [ + migrations.RunPython( + create_groups, + reverse_code=migrations.RunPython.noop, + atomic=True, + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index f084a5d8b..b2cffaf32 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -16,6 +16,7 @@ from .website import Website from .transition_domain import TransitionDomain from .verified_by_staff import VerifiedByStaff from .waffle_flag import WaffleFlag +from .portfolio import Portfolio __all__ = [ @@ -36,6 +37,7 @@ __all__ = [ "TransitionDomain", "VerifiedByStaff", "WaffleFlag", + "Portfolio", ] auditlog.register(Contact) @@ -55,3 +57,4 @@ auditlog.register(Website) auditlog.register(TransitionDomain) auditlog.register(VerifiedByStaff) auditlog.register(WaffleFlag) +auditlog.register(Portfolio) diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 62db04ac8..b6f2dd9a7 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -57,6 +57,16 @@ class DomainInformation(TimeStampedModel): help_text="Person who submitted the domain request", ) + # portfolio + portfolio = models.ForeignKey( + "registrar.Portfolio", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="DomainRequest_portfolio", + help_text="Portfolio associated with this domain", + ) + domain_request = models.OneToOneField( "registrar.DomainRequest", on_delete=models.PROTECT, diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 4f306f403..1c4725be1 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -303,6 +303,16 @@ class DomainRequest(TimeStampedModel): null=True, ) + # portfolio + portfolio = models.ForeignKey( + "registrar.Portfolio", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="DomainInformation_portfolio", + help_text="Portfolio associated with this domain request", + ) + # This is the domain request user who created this domain request. The contact # information that they gave is in the `submitter` field creator = models.ForeignKey( diff --git a/src/registrar/models/federal_agency.py b/src/registrar/models/federal_agency.py index cb09d12ac..8db415bbd 100644 --- a/src/registrar/models/federal_agency.py +++ b/src/registrar/models/federal_agency.py @@ -230,3 +230,8 @@ class FederalAgency(TimeStampedModel): FederalAgency.objects.bulk_create(agencies) except Exception as e: logger.error(f"Error creating federal agencies: {e}") + + @classmethod + def get_non_federal_agency(cls): + """Returns the non-federal agency.""" + return FederalAgency.objects.filter(agency="Non-Federal Agency").first() diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py new file mode 100644 index 000000000..a05422960 --- /dev/null +++ b/src/registrar/models/portfolio.py @@ -0,0 +1,99 @@ +from django.db import models + +from registrar.models.domain_request import DomainRequest +from registrar.models.federal_agency import FederalAgency + +from .utility.time_stamped_model import TimeStampedModel + + +# def get_default_federal_agency(): +# """returns non-federal agency""" +# return FederalAgency.objects.filter(agency="Non-Federal Agency").first() + + +class Portfolio(TimeStampedModel): + """ + Portfolio is used for organizing domains/domain-requests into + manageable groups. + """ + + # use the short names in Django admin + OrganizationChoices = DomainRequest.OrganizationChoices + StateTerritoryChoices = DomainRequest.StateTerritoryChoices + + # Stores who created this model. If no creator is specified in DJA, + # then the creator will default to the current request user""" + creator = models.ForeignKey("registrar.User", on_delete=models.PROTECT, help_text="Associated user", unique=False) + + notes = models.TextField( + null=True, + blank=True, + ) + + federal_agency = models.ForeignKey( + "registrar.FederalAgency", + on_delete=models.PROTECT, + help_text="Associated federal agency", + unique=False, + default=FederalAgency.get_non_federal_agency, + ) + + organization_type = models.CharField( + max_length=255, + choices=OrganizationChoices.choices, + null=True, + blank=True, + help_text="Type of organization", + ) + + organization_name = models.CharField( + null=True, + blank=True, + ) + + address_line1 = models.CharField( + null=True, + blank=True, + verbose_name="address line 1", + ) + + address_line2 = models.CharField( + null=True, + blank=True, + verbose_name="address line 2", + ) + + city = models.CharField( + null=True, + blank=True, + ) + + # (imports enums from domain_request.py) + state_territory = models.CharField( + max_length=2, + choices=StateTerritoryChoices.choices, + null=True, + blank=True, + verbose_name="state / territory", + ) + + zipcode = models.CharField( + max_length=10, + null=True, + blank=True, + verbose_name="zip code", + ) + + urbanization = models.CharField( + null=True, + blank=True, + help_text="Required for Puerto Rico only", + verbose_name="urbanization", + ) + + security_contact_email = models.EmailField( + null=True, + blank=True, + verbose_name="security contact e-mail", + max_length=320, + ) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index b24ffb47c..802974b6e 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -2291,6 +2291,7 @@ class TestDomainRequestAdmin(MockEppLib): "rejection_reason", "action_needed_reason", "federal_agency", + "portfolio", "creator", "investigator", "generic_org_type",