diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 04730371c..ef7888005 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1043,6 +1043,19 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): return super().changelist_view(request, extra_context=extra_context) +class SeniorOfficialAdmin(ListHeaderAdmin): + """Custom Senior Official Admin class.""" + + # NOTE: these are just placeholders. Not part of ACs (haven't been defined yet). Update in future tickets. + search_fields = ["first_name", "last_name", "email"] + search_help_text = "Search by first name, last name or email." + list_display = ["first_name", "last_name", "email"] + + # this ordering effects the ordering of results + # in autocomplete_fields for Senior Official + ordering = ["first_name", "last_name"] + + class WebsiteResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file""" @@ -2799,6 +2812,7 @@ admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) admin.site.register(models.Portfolio, PortfolioAdmin) admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin) +admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin) # Register our custom waffle implementations admin.site.register(models.WaffleFlag, WaffleFlagAdmin) diff --git a/src/registrar/migrations/0110_seniorofficial_portfolio_senior_official.py b/src/registrar/migrations/0110_seniorofficial_portfolio_senior_official.py new file mode 100644 index 000000000..c344898c3 --- /dev/null +++ b/src/registrar/migrations/0110_seniorofficial_portfolio_senior_official.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.10 on 2024-07-02 21:03 + +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0109_domaininformation_sub_organization_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SeniorOfficial", + 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)), + ("first_name", models.CharField(verbose_name="first name")), + ("last_name", models.CharField(verbose_name="last name")), + ("title", models.CharField(verbose_name="title / role")), + ( + "phone", + phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None), + ), + ("email", models.EmailField(blank=True, max_length=320, null=True)), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="portfolio", + name="senior_official", + field=models.ForeignKey( + blank=True, + help_text="Associated senior official", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="registrar.seniorofficial", + ), + ), + ] diff --git a/src/registrar/migrations/0111_create_groups_v15.py b/src/registrar/migrations/0111_create_groups_v15.py new file mode 100644 index 000000000..6b21f4b0d --- /dev/null +++ b/src/registrar/migrations/0111_create_groups_v15.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", "0110_seniorofficial_portfolio_senior_official"), + ] + + 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 376739826..a68633aff 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -19,6 +19,7 @@ from .waffle_flag import WaffleFlag from .portfolio import Portfolio from .domain_group import DomainGroup from .suborganization import Suborganization +from .senior_official import SeniorOfficial __all__ = [ @@ -42,6 +43,7 @@ __all__ = [ "Portfolio", "DomainGroup", "Suborganization", + "SeniorOfficial", ] auditlog.register(Contact) @@ -64,3 +66,4 @@ auditlog.register(WaffleFlag) auditlog.register(Portfolio) auditlog.register(DomainGroup) auditlog.register(Suborganization) +auditlog.register(SeniorOfficial) diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 0ea036bb7..c72f95c33 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -38,6 +38,15 @@ class Portfolio(TimeStampedModel): default=FederalAgency.get_non_federal_agency, ) + senior_official = models.ForeignKey( + "registrar.SeniorOfficial", + on_delete=models.PROTECT, + help_text="Associated senior official", + unique=False, + null=True, + blank=True, + ) + organization_type = models.CharField( max_length=255, choices=OrganizationChoices.choices, diff --git a/src/registrar/models/senior_official.py b/src/registrar/models/senior_official.py new file mode 100644 index 000000000..3cb064790 --- /dev/null +++ b/src/registrar/models/senior_official.py @@ -0,0 +1,50 @@ +from django.db import models + +from .utility.time_stamped_model import TimeStampedModel +from phonenumber_field.modelfields import PhoneNumberField # type: ignore + + +class SeniorOfficial(TimeStampedModel): + """ + Senior Official is a distinct Contact-like entity (NOT to be inherited + from Contacts) developed for the unique role these individuals have in + managing Portfolios. + """ + + first_name = models.CharField( + null=False, + blank=False, + verbose_name="first name", + ) + last_name = models.CharField( + null=False, + blank=False, + verbose_name="last name", + ) + title = models.CharField( + null=False, + blank=False, + verbose_name="title / role", + ) + phone = PhoneNumberField( + null=True, + blank=True, + ) + email = models.EmailField( + null=True, + blank=True, + max_length=320, + ) + + def get_formatted_name(self): + """Returns the contact's name in Western order.""" + names = [n for n in [self.first_name, self.last_name] if n] + return " ".join(names) if names else "Unknown" + + def __str__(self): + if self.first_name or self.last_name: + return self.get_formatted_name() + elif self.pk: + return str(self.pk) + else: + return ""