diff --git a/.github/ISSUE_TEMPLATE/developer-onboarding.md b/.github/ISSUE_TEMPLATE/developer-onboarding.md
index 4d231a039..eb547d5ab 100644
--- a/.github/ISSUE_TEMPLATE/developer-onboarding.md
+++ b/.github/ISSUE_TEMPLATE/developer-onboarding.md
@@ -16,6 +16,8 @@ assignees: abroddrick
There are several tools we use locally that you will need to have.
- [ ] [Install the cf CLI v7](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html#pkg-mac) for the ability to deploy
+ - If you are using Windows, installation information can be found [here](https://github.com/cloudfoundry/cli/wiki/V8-CLI-Installation-Guide#installers-and-compressed-binaries)
+ - Alternatively, for Windows, [consider using chocolately](https://community.chocolatey.org/packages/cloudfoundry-cli/7.2.0)
- [ ] Make sure you have `gpg` >2.1.7. Run `gpg --version` to check. If not, [install gnupg](https://formulae.brew.sh/formula/gnupg)
- [ ] Install the [Github CLI](https://cli.github.com/)
@@ -70,6 +72,7 @@ when setting up your key in Github.
Now test commit signing is working by checking out a branch (`yourname/test-commit-signing`) and making some small change to a file. Commit the change (it should prompt you for your GPG credential) and push it to Github. Look on Github at your branch and ensure the commit is `verified`.
+### MacOS
**Note:** if you are on a mac and not able to successfully create a signed commit, getting the following error:
```zsh
error: gpg failed to sign the data
@@ -90,6 +93,15 @@ or
source ~/.zshrc
```
+### Windows
+If GPG doesn't work out of the box with git for you:
+- You can [download the GPG binary directly](https://gnupg.org/download/).
+- It may be helpful to use [gpg4win](https://www.gpg4win.org/get-gpg4win.html).
+
+From there, you should be able to access gpg through the terminal.
+
+Additionally, consider a gpg key manager like Kleopatra if you run into issues with environment variables or with the gpg service not running on startup.
+
## Setting up developer sandbox
We have three types of environments: stable, staging, and sandbox. Stable (production)and staging (pre-prod) get deployed via tagged release, and developer sandboxes are given to get.gov developers to mess around in a production-like environment without disrupting stable or staging. Each sandbox is namespaced and will automatically be deployed too when the appropriate branch syntax is used for that space in an open pull request. There are several things you need to setup to make the sandbox work for a developer.
diff --git a/.github/ISSUE_TEMPLATE/issue-default.yml b/.github/ISSUE_TEMPLATE/issue-default.yml
index 47fb2c226..254078246 100644
--- a/.github/ISSUE_TEMPLATE/issue-default.yml
+++ b/.github/ISSUE_TEMPLATE/issue-default.yml
@@ -31,8 +31,8 @@ body:
attributes:
label: Links to other issues
description: |
- "Add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to)."
- placeholder: 🔄 Relates to...
+ "With a `-` to start the line, add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to)."
+ placeholder: "- 🔄 Relates to..."
- type: markdown
id: note
attributes:
diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml
index 84f228893..4bd7f99dd 100644
--- a/.github/workflows/deploy-sandbox.yaml
+++ b/.github/workflows/deploy-sandbox.yaml
@@ -28,6 +28,7 @@ jobs:
|| startsWith(github.head_ref, 'hotgov/')
|| startsWith(github.head_ref, 'litterbox/')
|| startsWith(github.head_ref, 'ag/')
+ || startsWith(github.head_ref, 'ms/')
outputs:
environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest"
diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml
index 81368f6e9..3ebee59f9 100644
--- a/.github/workflows/migrate.yaml
+++ b/.github/workflows/migrate.yaml
@@ -16,6 +16,7 @@ on:
- stable
- staging
- development
+ - ms
- ag
- litterbox
- hotgov
diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml
index ad325c50a..49e4b5e5f 100644
--- a/.github/workflows/reset-db.yaml
+++ b/.github/workflows/reset-db.yaml
@@ -16,6 +16,7 @@ on:
options:
- staging
- development
+ - ms
- ag
- litterbox
- hotgov
diff --git a/docs/developer/README.md b/docs/developer/README.md
index 72f6b9f20..f63f01938 100644
--- a/docs/developer/README.md
+++ b/docs/developer/README.md
@@ -353,49 +353,6 @@ cf env getgov-{app name}
Then, copy the variables under the section labled `s3`.
-## Signals
-The application uses [Django signals](https://docs.djangoproject.com/en/5.0/topics/signals/). In particular, it uses a subset of prebuilt signals called [model signals](https://docs.djangoproject.com/en/5.0/ref/signals/#module-django.db.models.signals).
-
-Per Django, signals "[...allow certain senders to notify a set of receivers that some action has taken place.](https://docs.djangoproject.com/en/5.0/topics/signals/#module-django.dispatch)"
-
-In other words, signals are a mechanism that allows different parts of an application to communicate with each other by sending and receiving notifications when events occur. When an event occurs (such as creating, updating, or deleting a record), signals can automatically trigger specific actions in response. This allows different parts of an application to stay synchronized without tightly coupling the component.
-
-### Rules of use
-When using signals, try to adhere to these guidelines:
-1. Don't use signals when you can use another method, such as an override of `save()` or `__init__`.
-2. Document its usage in this readme (or another centralized location), as well as briefly on the underlying class it is associated with. For instance, since the `handle_profile` directly affects the class `Contact`, the class description notes this and links to [signals.py](../../src/registrar/signals.py).
-3. Where possible, avoid chaining signals together (i.e. a signal that calls a signal). If this has to be done, clearly document the flow.
-4. Minimize logic complexity within the signal as much as possible.
-
-### When should you use signals?
-Generally, you would use signals when you want an event to be synchronized across multiple areas of code at once (such as with two models or more models at once) in a way that would otherwise be difficult to achieve by overriding functions.
-
-However, in most scenarios, if you can get away with avoiding signals - you should. The reasoning for this is that [signals give the appearance of loose coupling, but they can quickly lead to code that is hard to understand, adjust and debug](https://docs.djangoproject.com/en/5.0/topics/signals/#module-django.dispatch).
-
-Consider using signals when:
-1. Synchronizing events across multiple models or areas of code.
-2. Performing logic before or after saving a model to the database (when otherwise difficult through `save()`).
-3. Encountering an import loop when overriding functions such as `save()`.
-4. You are otherwise unable to achieve the intended behavior by overrides or other means.
-5. (Rare) Offloading tasks when multi-threading.
-
-For the vast majority of use cases, the [pre_save](https://docs.djangoproject.com/en/5.0/ref/signals/#pre-save) and [post_save](https://docs.djangoproject.com/en/5.0/ref/signals/#post-save) signals are sufficient in terms of model-to-model management.
-
-### Where should you use them?
-This project compiles signals in a unified location to maintain readability. If you are adding a signal or otherwise utilizing one, you should always define them in [signals.py](../../src/registrar/signals.py). Except under rare circumstances, this should be adhered to for the reasons mentioned above.
-
-### How are we currently using signals?
-At the time of writing, we currently only use signals for the Contact and User objects when synchronizing data returned from Login.gov. This is because the `Contact` object holds information that the user specified in our system, whereas the `User` object holds information that was specified in Login.gov.
-
-To keep our signal usage coherent and well-documented, add to this document when a new function is added for ease of reference and use.
-
-#### handle_profile
-This function is triggered by the post_save event on the User model, designed to manage the synchronization between User and Contact entities. It operates under the following conditions:
-
-1. For New Users: Upon the creation of a new user, it checks for an existing `Contact` by email. If no matching contact is found, it creates a new Contact using the user's details from Login.gov. If a matching contact is found, it associates this contact with the user. In cases where multiple contacts with the same email exist, it logs a warning and associates the first contact found.
-
-2. For Existing Users: For users logging in subsequent times, the function ensures that any updates from Login.gov are applied to the associated User record. However, it does not alter any existing Contact records.
-
## Disable email sending (toggling the disable_email_sending flag)
1. On the app, navigate to `\admin`.
2. Under models, click `Waffle flags`.
diff --git a/ops/manifests/manifest-ms.yaml b/ops/manifests/manifest-ms.yaml
new file mode 100644
index 000000000..153ee5f08
--- /dev/null
+++ b/ops/manifests/manifest-ms.yaml
@@ -0,0 +1,32 @@
+---
+applications:
+- name: getgov-ms
+ buildpacks:
+ - python_buildpack
+ path: ../../src
+ instances: 1
+ memory: 512M
+ stack: cflinuxfs4
+ timeout: 180
+ command: ./run.sh
+ health-check-type: http
+ health-check-http-endpoint: /health
+ health-check-invocation-timeout: 40
+ env:
+ # Send stdout and stderr straight to the terminal without buffering
+ PYTHONUNBUFFERED: yup
+ # Tell Django where to find its configuration
+ DJANGO_SETTINGS_MODULE: registrar.config.settings
+ # Tell Django where it is being hosted
+ DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov
+ # Tell Django how much stuff to log
+ DJANGO_LOG_LEVEL: INFO
+ # default public site location
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
+ # Flag to disable/enable features in prod environments
+ IS_PRODUCTION: False
+ routes:
+ - route: getgov-ms.app.cloud.gov
+ services:
+ - getgov-credentials
+ - getgov-ms-database
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index efb662859..f1a8615a3 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -19,7 +19,7 @@ from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch
-from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website
+from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR
@@ -448,8 +448,9 @@ class AdminSortFields:
sort_mapping = {
# == Contact == #
"other_contacts": (Contact, _name_sort),
- "senior_official": (Contact, _name_sort),
"submitter": (Contact, _name_sort),
+ # == Senior Official == #
+ "senior_official": (SeniorOfficial, _name_sort),
# == User == #
"creator": (User, _name_sort),
"user": (User, _name_sort),
@@ -597,33 +598,6 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
return filters
-class UserContactInline(admin.StackedInline):
- """Edit a user's profile on the user page."""
-
- model = models.Contact
-
- # Read only that we'll leverage for CISA Analysts
- analyst_readonly_fields = [
- "user",
- "email",
- ]
-
- def get_readonly_fields(self, request, obj=None):
- """Set the read-only state on form elements.
- We have 1 conditions that determine which fields are read-only:
- admin user permissions.
- """
-
- readonly_fields = list(self.readonly_fields)
-
- if request.user.has_perm("registrar.full_access_permission"):
- return readonly_fields
- # Return restrictive Read-only fields for analysts and
- # users who might not belong to groups
- readonly_fields.extend([field for field in self.analyst_readonly_fields])
- return readonly_fields # Read-only fields for analysts
-
-
class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"""Custom user admin class to use our inlines."""
@@ -640,8 +614,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
_meta = Meta()
- inlines = [UserContactInline]
-
list_display = (
"username",
"overridden_email_field",
@@ -919,30 +891,20 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = [
"name",
"email",
- "user_exists",
]
# this ordering effects the ordering of results
- # in autocomplete_fields for user
+ # in autocomplete_fields
ordering = ["first_name", "last_name", "email"]
fieldsets = [
(
None,
- {"fields": ["user", "first_name", "middle_name", "last_name", "title", "email", "phone"]},
+ {"fields": ["first_name", "middle_name", "last_name", "title", "email", "phone"]},
)
]
- autocomplete_fields = ["user"]
-
change_form_template = "django/admin/email_clipboard_change_form.html"
- def user_exists(self, obj):
- """Check if the Contact has a related User"""
- return "Yes" if obj.user is not None else "No"
-
- user_exists.short_description = "Is user" # type: ignore
- user_exists.admin_order_field = "user" # type: ignore
-
# We name the custom prop 'contact' because linter
# is not allowing a short_description attr on it
# This gets around the linter limitation, for now.
@@ -960,10 +922,7 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
name.admin_order_field = "first_name" # type: ignore
# Read only that we'll leverage for CISA Analysts
- analyst_readonly_fields = [
- "user",
- "email",
- ]
+ analyst_readonly_fields: list[str] = ["email"]
def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements.
@@ -1043,6 +1002,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"""
@@ -2813,6 +2785,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/apps.py b/src/registrar/apps.py
index fcb5c17fd..b5952208b 100644
--- a/src/registrar/apps.py
+++ b/src/registrar/apps.py
@@ -5,12 +5,3 @@ class RegistrarConfig(AppConfig):
"""Configure signal handling for our registrar Django application."""
name = "registrar"
-
- def ready(self):
- """Runs when all Django applications have been loaded.
-
- We use it here to load signals that connect related models.
- """
- # noqa here because we are importing something to make the signals
- # get registered, but not using what we import
- from . import signals # noqa
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 688a3e8ca..1b8a5005f 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -660,6 +660,7 @@ ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov",
"getgov-development.app.cloud.gov",
+ "getgov-ms.app.cloud.gov",
"getgov-ag.app.cloud.gov",
"getgov-litterbox.app.cloud.gov",
"getgov-hotgov.app.cloud.gov",
diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py
index 2aa9d224b..74fd4d15d 100644
--- a/src/registrar/fixtures_users.py
+++ b/src/registrar/fixtures_users.py
@@ -22,6 +22,11 @@ class UserFixture:
"""
ADMINS = [
+ {
+ "username": "be17c826-e200-4999-9389-2ded48c43691",
+ "first_name": "Matthew",
+ "last_name": "Spence",
+ },
{
"username": "5f283494-31bd-49b5-b024-a7e7cae00848",
"first_name": "Rachid",
@@ -115,6 +120,11 @@ class UserFixture:
]
STAFF = [
+ {
+ "username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
+ "first_name": "Matthew-Analyst",
+ "last_name": "Spence-Analyst",
+ },
{
"username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844",
"first_name": "Rachid-Analyst",
diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py
index ebfc2ea88..a0c71d48c 100644
--- a/src/registrar/forms/__init__.py
+++ b/src/registrar/forms/__init__.py
@@ -4,7 +4,7 @@ from .domain import (
NameserverFormset,
DomainSecurityEmailForm,
DomainOrgNameAddressForm,
- ContactForm,
+ UserForm,
SeniorOfficialContactForm,
DomainDnssecForm,
DomainDsdataFormset,
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
index 4e9c5e1d9..9b8f1b7fc 100644
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -16,7 +16,7 @@ from registrar.utility.errors import (
SecurityEmailErrorCodes,
)
-from ..models import Contact, DomainInformation, Domain
+from ..models import Contact, DomainInformation, Domain, User
from .common import (
ALGORITHM_CHOICES,
DIGEST_TYPE_CHOICES,
@@ -203,6 +203,63 @@ NameserverFormset = formset_factory(
)
+class UserForm(forms.ModelForm):
+ """Form for updating users."""
+
+ email = forms.EmailField(max_length=None)
+
+ class Meta:
+ model = User
+ fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
+ widgets = {
+ "first_name": forms.TextInput,
+ "middle_name": forms.TextInput,
+ "last_name": forms.TextInput,
+ "title": forms.TextInput,
+ "email": forms.EmailInput,
+ "phone": RegionalPhoneNumberWidget,
+ }
+
+ # the database fields have blank=True so ModelForm doesn't create
+ # required fields by default. Use this list in __init__ to mark each
+ # of these fields as required
+ required = ["first_name", "last_name", "title", "email", "phone"]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # take off maxlength attribute for the phone number field
+ # which interferes with out input_with_errors template tag
+ self.fields["phone"].widget.attrs.pop("maxlength", None)
+
+ # Define a custom validator for the email field with a custom error message
+ email_max_length_validator = MaxLengthValidator(320, message="Response must be less than 320 characters.")
+ self.fields["email"].validators.append(email_max_length_validator)
+
+ for field_name in self.required:
+ self.fields[field_name].required = True
+
+ # Set custom form label
+ self.fields["middle_name"].label = "Middle name (optional)"
+
+ # Set custom error messages
+ self.fields["first_name"].error_messages = {"required": "Enter your first name / given name."}
+ self.fields["last_name"].error_messages = {"required": "Enter your last name / family name."}
+ self.fields["title"].error_messages = {
+ "required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
+ }
+ self.fields["email"].error_messages = {
+ "required": "Enter your email address in the required format, like name@example.com."
+ }
+ self.fields["phone"].error_messages["required"] = "Enter your phone number."
+ self.domainInfo = None
+
+ def set_domain_info(self, domainInfo):
+ """Set the domain information for the form.
+ The form instance is associated with the contact itself. In order to access the associated
+ domain information object, this needs to be set in the form by the view."""
+ self.domainInfo = domainInfo
+
+
class ContactForm(forms.ModelForm):
"""Form for updating contacts."""
diff --git a/src/registrar/forms/user_profile.py b/src/registrar/forms/user_profile.py
index 682e1a5df..bfdcd0da8 100644
--- a/src/registrar/forms/user_profile.py
+++ b/src/registrar/forms/user_profile.py
@@ -1,6 +1,6 @@
from django import forms
-from registrar.models.contact import Contact
+from registrar.models.user import User
from django.core.validators import MaxLengthValidator
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@@ -13,7 +13,7 @@ class UserProfileForm(forms.ModelForm):
redirect = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta:
- model = Contact
+ model = User
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
widgets = {
"first_name": forms.TextInput,
diff --git a/src/registrar/management/commands/copy_names_from_contacts_to_users.py b/src/registrar/management/commands/copy_names_from_contacts_to_users.py
deleted file mode 100644
index 384029400..000000000
--- a/src/registrar/management/commands/copy_names_from_contacts_to_users.py
+++ /dev/null
@@ -1,242 +0,0 @@
-import logging
-import argparse
-import sys
-
-from django.core.management import BaseCommand
-
-from registrar.management.commands.utility.terminal_helper import (
- TerminalColors,
- TerminalHelper,
-)
-from registrar.models.contact import Contact
-from registrar.models.user import User
-from registrar.models.utility.domain_helper import DomainHelper
-
-logger = logging.getLogger(__name__)
-
-
-class Command(BaseCommand):
- help = """Copy first and last names from a contact to
- a related user if it exists and if its first and last name
- properties are null or blank strings."""
-
- # ======================================================
- # ===================== ARGUMENTS =====================
- # ======================================================
- def add_arguments(self, parser):
- parser.add_argument("--debug", action=argparse.BooleanOptionalAction)
-
- # ======================================================
- # ===================== PRINTING ======================
- # ======================================================
- def print_debug_mode_statements(self, debug_on: bool):
- """Prints additional terminal statements to indicate if --debug
- or --limitParse are in use"""
- TerminalHelper.print_conditional(
- debug_on,
- f"""{TerminalColors.OKCYAN}
- ----------DEBUG MODE ON----------
- Detailed print statements activated.
- {TerminalColors.ENDC}
- """,
- )
-
- def print_summary_of_findings(
- self,
- skipped_contacts,
- eligible_users,
- processed_users,
- debug_on,
- ):
- """Prints to terminal a summary of findings from
- copying first and last names from contacts to users"""
-
- total_eligible_users = len(eligible_users)
- total_skipped_contacts = len(skipped_contacts)
- total_processed_users = len(processed_users)
-
- logger.info(
- f"""{TerminalColors.OKGREEN}
- ============= FINISHED ===============
- Skipped {total_skipped_contacts} contacts
- Found {total_eligible_users} users linked to contacts
- Processed {total_processed_users} users
- {TerminalColors.ENDC}
- """ # noqa
- )
-
- # DEBUG:
- TerminalHelper.print_conditional(
- debug_on,
- f"""{TerminalColors.YELLOW}
- ======= DEBUG OUTPUT =======
- Users who have a linked contact:
- {eligible_users}
-
- Processed users (users who have a linked contact and a missing first or last name):
- {processed_users}
-
- ===== SKIPPED CONTACTS =====
- {skipped_contacts}
-
- {TerminalColors.ENDC}
- """,
- )
-
- # ======================================================
- # =================== USER =====================
- # ======================================================
- def update_user(self, contact: Contact, debug_on: bool):
- """Given a contact with a first_name and last_name, find & update an existing
- corresponding user if her first_name and last_name are null.
-
- Returns tuple of eligible (is linked to the contact) and processed
- (first and last are blank) users.
- """
-
- user_exists = User.objects.filter(contact=contact).exists()
- if user_exists:
- try:
- # ----------------------- UPDATE USER -----------------------
- # ---- GET THE USER
- eligible_user = User.objects.get(contact=contact)
- processed_user = None
- # DEBUG:
- TerminalHelper.print_conditional(
- debug_on,
- f"""{TerminalColors.YELLOW}
- > Found linked user for contact:
- {contact} {contact.email} {contact.first_name} {contact.last_name}
- > The linked user is {eligible_user} {eligible_user.username}
- {TerminalColors.ENDC}""", # noqa
- )
-
- # Get the fields that exist on both User and Contact. Excludes id.
- common_fields = DomainHelper.get_common_fields(User, Contact)
- if "email" in common_fields:
- # Don't change the email field.
- common_fields.remove("email")
-
- for field in common_fields:
- # Grab the value that contact has stored for this field
- new_value = getattr(contact, field)
-
- # Set it on the user field
- setattr(eligible_user, field, new_value)
-
- eligible_user.save()
- processed_user = eligible_user
-
- return (
- eligible_user,
- processed_user,
- )
-
- except Exception as error:
- logger.warning(
- f"""
- {TerminalColors.FAIL}
- !!! ERROR: An exception occured in the
- User table for the following user:
- {contact.email} {contact.first_name} {contact.last_name}
-
- Exception is: {error}
- ----------TERMINATING----------"""
- )
- sys.exit()
- else:
- return None, None
-
- # ======================================================
- # ================= PROCESS CONTACTS ==================
- # ======================================================
-
- def process_contacts(
- self,
- debug_on,
- skipped_contacts=[],
- eligible_users=[],
- processed_users=[],
- ):
- for contact in Contact.objects.all():
- TerminalHelper.print_conditional(
- debug_on,
- f"{TerminalColors.OKCYAN}"
- "Processing Contact: "
- f"{contact.email},"
- f" {contact.first_name},"
- f" {contact.last_name}"
- f"{TerminalColors.ENDC}",
- )
-
- # ======================================================
- # ====================== USER =======================
- (eligible_user, processed_user) = self.update_user(contact, debug_on)
-
- debug_string = ""
- if eligible_user:
- # ---------------- UPDATED ----------------
- eligible_users.append(contact.email)
- debug_string = f"eligible user: {eligible_user}"
- if processed_user:
- processed_users.append(contact.email)
- debug_string = f"processed user: {processed_user}"
- else:
- skipped_contacts.append(contact.email)
- debug_string = f"skipped user: {contact.email}"
-
- # DEBUG:
- TerminalHelper.print_conditional(
- debug_on,
- (f"{TerminalColors.OKCYAN} {debug_string} {TerminalColors.ENDC}"),
- )
-
- return (
- skipped_contacts,
- eligible_users,
- processed_users,
- )
-
- # ======================================================
- # ===================== HANDLE ========================
- # ======================================================
- def handle(
- self,
- **options,
- ):
- """Parse entries in Contact table
- and update valid corresponding entries in the
- User table."""
-
- # grab command line arguments and store locally...
- debug_on = options.get("debug")
-
- self.print_debug_mode_statements(debug_on)
-
- logger.info(
- f"""{TerminalColors.OKCYAN}
- ==========================
- Beginning Data Transfer
- ==========================
- {TerminalColors.ENDC}"""
- )
-
- logger.info(
- f"""{TerminalColors.OKCYAN}
- ========= Adding Domains and Domain Invitations =========
- {TerminalColors.ENDC}"""
- )
- (
- skipped_contacts,
- eligible_users,
- processed_users,
- ) = self.process_contacts(
- debug_on,
- )
-
- self.print_summary_of_findings(
- skipped_contacts,
- eligible_users,
- processed_users,
- debug_on,
- )
diff --git a/src/registrar/management/commands/generate_current_federal_report.py b/src/registrar/management/commands/generate_current_federal_report.py
index 6516bf99b..97d4fd7e4 100644
--- a/src/registrar/management/commands/generate_current_federal_report.py
+++ b/src/registrar/management/commands/generate_current_federal_report.py
@@ -50,7 +50,7 @@ class Command(BaseCommand):
# Generate a file locally for upload
with open(file_path, "w") as file:
- csv_export.export_data_federal_to_csv(file)
+ csv_export.DomainDataFederal.export_data_to_csv(file)
if check_path and not os.path.exists(file_path):
raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")
diff --git a/src/registrar/management/commands/generate_current_full_report.py b/src/registrar/management/commands/generate_current_full_report.py
index be810ee10..4bcb9f502 100644
--- a/src/registrar/management/commands/generate_current_full_report.py
+++ b/src/registrar/management/commands/generate_current_full_report.py
@@ -49,7 +49,7 @@ class Command(BaseCommand):
# Generate a file locally for upload
with open(file_path, "w") as file:
- csv_export.export_data_full_to_csv(file)
+ csv_export.DomainDataFull.export_data_to_csv(file)
if check_path and not os.path.exists(file_path):
raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")
diff --git a/src/registrar/management/commands/import_tables.py b/src/registrar/management/commands/import_tables.py
index cb78e13bd..62562e7f7 100644
--- a/src/registrar/management/commands/import_tables.py
+++ b/src/registrar/management/commands/import_tables.py
@@ -65,13 +65,6 @@ class Command(BaseCommand):
resourcename = f"{table_name}Resource"
- # if table_name is Contact, clean the table first
- # User table is loaded before Contact, and signals create
- # rows in Contact table which break the import, so need
- # to be cleaned again before running import on Contact table
- if table_name == "Contact":
- self.clean_table(table_name)
-
# Define the directory and the pattern for csv filenames
tmp_dir = "tmp"
pattern = f"{table_name}_"
diff --git a/src/registrar/management/commands/repopulate_domain_information_senior_official.py b/src/registrar/management/commands/repopulate_domain_information_senior_official.py
new file mode 100644
index 000000000..540f88154
--- /dev/null
+++ b/src/registrar/management/commands/repopulate_domain_information_senior_official.py
@@ -0,0 +1,76 @@
+import argparse
+import csv
+import logging
+import os
+from django.core.management import BaseCommand
+from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
+from registrar.models import DomainInformation
+
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand, PopulateScriptTemplate):
+ """
+ This command uses the PopulateScriptTemplate,
+ which provides reusable logging and bulk updating functions for mass-updating fields.
+ """
+
+ help = "Loops through each valid DomainInformation object and updates its Senior Official"
+ prompt_title = "Do you wish to update all Senior Officials for Domain Information?"
+
+ def handle(self, domain_info_csv_path, **kwargs):
+ """Loops through each valid DomainInformation object and updates its senior official field"""
+
+ # Check if the provided file path is valid.
+ if not os.path.isfile(domain_info_csv_path):
+ raise argparse.ArgumentTypeError(f"Invalid file path '{domain_info_csv_path}'")
+
+ # Simple check to make sure we don't accidentally pass in the wrong file. Crude but it works.
+ if "information" not in domain_info_csv_path.lower():
+ raise argparse.ArgumentTypeError(f"Invalid file for domain information: '{domain_info_csv_path}'")
+
+ # Get all ao data.
+ self.ao_dict = {}
+ self.ao_dict = self.read_csv_file_and_get_contacts(domain_info_csv_path)
+
+ self.mass_update_records(
+ DomainInformation, filter_conditions={"senior_official__isnull": True}, fields_to_update=["senior_official"]
+ )
+
+ def add_arguments(self, parser):
+ """Add command line arguments."""
+ parser.add_argument(
+ "--domain_info_csv_path", help="A csv containing the domain information id and the contact id"
+ )
+
+ def read_csv_file_and_get_contacts(self, file):
+ dict_data = {}
+ with open(file, "r") as requested_file:
+ reader = csv.DictReader(requested_file)
+ for row in reader:
+ domain_info_id = row.get("id")
+ ao_id = row.get("authorizing_official")
+ if ao_id:
+ ao_id = int(ao_id)
+ if domain_info_id and ao_id:
+ dict_data[int(domain_info_id)] = ao_id
+
+ return dict_data
+
+ def update_record(self, record: DomainInformation):
+ """Defines how we update the senior official field on each record."""
+ record.senior_official_id = self.ao_dict.get(record.id)
+ logger.info(f"{TerminalColors.OKCYAN}Updating {str(record)} => {record.senior_official}{TerminalColors.ENDC}")
+
+ def should_skip_record(self, record) -> bool: # noqa
+ """Defines the conditions in which we should skip updating a record."""
+ # Don't update this record if there isn't ao data to pull from
+ if self.ao_dict.get(record.id) is None:
+ logger.info(
+ f"{TerminalColors.YELLOW}Skipping update for {str(record)} => "
+ f"Missing authorizing_official data.{TerminalColors.ENDC}"
+ )
+ return True
+ else:
+ return False
diff --git a/src/registrar/management/commands/repopulate_domain_request_senior_official.py b/src/registrar/management/commands/repopulate_domain_request_senior_official.py
new file mode 100644
index 000000000..37fcea03e
--- /dev/null
+++ b/src/registrar/management/commands/repopulate_domain_request_senior_official.py
@@ -0,0 +1,81 @@
+import argparse
+import csv
+import logging
+import os
+from django.core.management import BaseCommand
+from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
+from registrar.models import DomainRequest
+
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand, PopulateScriptTemplate):
+ """
+ This command uses the PopulateScriptTemplate,
+ which provides reusable logging and bulk updating functions for mass-updating fields.
+ """
+
+ help = """Loops through each valid DomainRequest object and updates its senior official field"""
+ prompt_title = "Do you wish to update all Senior Officials for Domain Requests?"
+
+ def handle(self, domain_request_csv_path, **kwargs):
+ """Loops through each valid DomainRequest object and updates its senior official field"""
+
+ # Check if the provided file path is valid.
+ if not os.path.isfile(domain_request_csv_path):
+ raise argparse.ArgumentTypeError(f"Invalid file path '{domain_request_csv_path}'")
+
+ # Simple check to make sure we don't accidentally pass in the wrong file. Crude but it works.
+ if "request" not in domain_request_csv_path.lower():
+ raise argparse.ArgumentTypeError(f"Invalid file for domain requests: '{domain_request_csv_path}'")
+
+ # Get all ao data.
+ self.ao_dict = {}
+ self.ao_dict = self.read_csv_file_and_get_contacts(domain_request_csv_path)
+
+ self.mass_update_records(
+ DomainRequest,
+ filter_conditions={
+ "senior_official__isnull": True,
+ },
+ fields_to_update=["senior_official"],
+ )
+
+ def add_arguments(self, parser):
+ """Add command line arguments."""
+ parser.add_argument(
+ "--domain_request_csv_path", help="A csv containing the domain request id and the contact id"
+ )
+
+ def read_csv_file_and_get_contacts(self, file):
+ dict_data: dict = {}
+ with open(file, "r") as requested_file:
+ reader = csv.DictReader(requested_file)
+ for row in reader:
+ domain_request_id = row.get("id")
+ ao_id = row.get("authorizing_official")
+ if ao_id:
+ ao_id = int(ao_id)
+ if domain_request_id and ao_id:
+ dict_data[int(domain_request_id)] = ao_id
+
+ return dict_data
+
+ def update_record(self, record: DomainRequest):
+ """Defines how we update the federal_type field on each record."""
+ record.senior_official_id = self.ao_dict.get(record.id)
+ # record.senior_official = Contact.objects.get(id=contact_id)
+ logger.info(f"{TerminalColors.OKCYAN}Updating {str(record)} => {record.senior_official}{TerminalColors.ENDC}")
+
+ def should_skip_record(self, record) -> bool: # noqa
+ """Defines the conditions in which we should skip updating a record."""
+ # Don't update this record if there isn't ao data to pull from
+ if self.ao_dict.get(record.id) is None:
+ logger.info(
+ f"{TerminalColors.YELLOW}Skipping update for {str(record)} => "
+ f"Missing authorizing_official data.{TerminalColors.ENDC}"
+ )
+ return True
+ else:
+ return False
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/migrations/0112_remove_contact_registrar_c_user_id_4059c4_idx_and_more.py b/src/registrar/migrations/0112_remove_contact_registrar_c_user_id_4059c4_idx_and_more.py
new file mode 100644
index 000000000..db9b7970a
--- /dev/null
+++ b/src/registrar/migrations/0112_remove_contact_registrar_c_user_id_4059c4_idx_and_more.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.2.10 on 2024-07-02 19:52
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0111_create_groups_v15"),
+ ]
+
+ operations = [
+ migrations.RemoveIndex(
+ model_name="contact",
+ name="registrar_c_user_id_4059c4_idx",
+ ),
+ migrations.RemoveField(
+ model_name="contact",
+ name="user",
+ ),
+ ]
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/contact.py b/src/registrar/models/contact.py
index f94938dd1..903633749 100644
--- a/src/registrar/models/contact.py
+++ b/src/registrar/models/contact.py
@@ -8,30 +8,15 @@ from phonenumber_field.modelfields import PhoneNumberField # type: ignore
class Contact(TimeStampedModel):
"""
Contact information follows a similar pattern for each contact.
-
- This model uses signals [as defined in [signals.py](../../src/registrar/signals.py)].
- When a new user is created through Login.gov, a contact object will be created and
- associated on the `user` field.
-
- If the `user` object already exists, the underlying user object
- will be updated if any updates are made to it through Login.gov.
"""
class Meta:
"""Contains meta information about this class"""
indexes = [
- models.Index(fields=["user"]),
models.Index(fields=["email"]),
]
- user = models.OneToOneField(
- "registrar.User",
- null=True,
- blank=True,
- on_delete=models.SET_NULL,
- )
-
first_name = models.CharField(
null=True,
blank=True,
@@ -110,38 +95,6 @@ class Contact(TimeStampedModel):
def has_contact_info(self):
return bool(self.title or self.email or self.phone)
- def save(self, *args, **kwargs):
- # Call the parent class's save method to perform the actual save
- super().save(*args, **kwargs)
-
- if self.user:
- updated = False
-
- # Update first name and last name if necessary
- if not self.user.first_name or not self.user.last_name:
- self.user.first_name = self.first_name
- self.user.last_name = self.last_name
- updated = True
-
- # Update middle_name if necessary
- if not self.user.middle_name:
- self.user.middle_name = self.middle_name
- updated = True
-
- # Update phone if necessary
- if not self.user.phone:
- self.user.phone = self.phone
- updated = True
-
- # Update title if necessary
- if not self.user.title:
- self.user.title = self.title
- updated = True
-
- # Save user if any updates were made
- if updated:
- self.user.save()
-
def __str__(self):
if self.first_name or self.last_name:
return self.get_formatted_name()
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 767227499..7fdc56971 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -151,6 +151,11 @@ class Domain(TimeStampedModel, DomainHelper):
# previously existed but has been deleted from the registry
DELETED = "deleted", "Deleted"
+ @classmethod
+ def get_state_label(cls, state: str):
+ """Returns the associated label for a given state value"""
+ return cls(state).label if state else None
+
@classmethod
def get_help_text(cls, state) -> str:
"""Returns a help message for a desired state. If none is found, an empty string is returned"""
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index aac356e38..a7252e16b 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -135,6 +135,13 @@ class DomainRequest(TimeStampedModel):
@classmethod
def get_org_label(cls, org_name: str):
"""Returns the associated label for a given org name"""
+ # This is an edgecase on domains with no org.
+ # This unlikely to happen but
+ # a break will occur in certain edge cases without this.
+ # (more specifically, csv exports).
+ if not org_name:
+ return None
+
org_names = org_name.split("_election")
if len(org_names) > 0:
org_name = org_names[0]
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 ""
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index bb0276607..87b7799d3 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -23,10 +23,6 @@ class User(AbstractUser):
A custom user model that performs identically to the default user model
but can be customized later.
- This model uses signals [as defined in [signals.py](../../src/registrar/signals.py)].
- When a new user is created through Login.gov, a contact object will be created and
- associated on the contacts `user` field.
-
If the `user` object already exists, said user object
will be updated if any updates are made to it through Login.gov.
"""
@@ -113,15 +109,11 @@ class User(AbstractUser):
Tracks if the user finished their profile setup or not. This is so
we can globally enforce that new users provide additional account information before proceeding.
"""
-
- # Change this to self once the user and contact objects are merged.
- # For now, since they are linked, lets test on the underlying contact object.
- user_info = self.contact # noqa
user_values = [
- user_info.first_name,
- user_info.last_name,
- user_info.title,
- user_info.phone,
+ self.first_name,
+ self.last_name,
+ self.title,
+ self.phone,
]
return None not in user_values
@@ -169,8 +161,13 @@ class User(AbstractUser):
"""Return count of ineligible requests"""
return self.domain_requests_created.filter(status=DomainRequest.DomainRequestStatus.INELIGIBLE).count()
+ def get_formatted_name(self):
+ """Returns the contact's name in Western order."""
+ names = [n for n in [self.first_name, self.middle_name, self.last_name] if n]
+ return " ".join(names) if names else "Unknown"
+
def has_contact_info(self):
- return bool(self.contact.title or self.contact.email or self.contact.phone)
+ return bool(self.title or self.email or self.phone)
@classmethod
def needs_identity_verification(cls, email, uuid):
diff --git a/src/registrar/signals.py b/src/registrar/signals.py
deleted file mode 100644
index bc0480b2a..000000000
--- a/src/registrar/signals.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import logging
-
-from django.db.models.signals import post_save
-from django.dispatch import receiver
-
-from .models import User, Contact
-
-
-logger = logging.getLogger(__name__)
-
-
-@receiver(post_save, sender=User)
-def handle_profile(sender, instance, **kwargs):
- """Method for when a User is saved.
-
- A first time registrant may have been invited, so we'll search for a matching
- Contact record, by email address, and associate them, if possible.
-
- A first time registrant may not have a matching Contact, so we'll create one,
- copying the contact values we received from Login.gov in order to initialize it.
-
- During subsequent login, a User record may be updated with new data from Login.gov,
- but in no case will we update contact values on an existing Contact record.
- """
-
- first_name = getattr(instance, "first_name", "")
- middle_name = getattr(instance, "middle_name", "")
- last_name = getattr(instance, "last_name", "")
- email = getattr(instance, "email", "")
- phone = getattr(instance, "phone", "")
- title = getattr(instance, "title", "")
-
- is_new_user = kwargs.get("created", False)
-
- if is_new_user:
- contacts = Contact.objects.filter(email=email)
- else:
- contacts = Contact.objects.filter(user=instance)
-
- if len(contacts) == 0: # no matching contact
- Contact.objects.create(
- user=instance,
- first_name=first_name,
- middle_name=middle_name,
- last_name=last_name,
- email=email,
- phone=phone,
- title=title,
- )
-
- if len(contacts) >= 1 and is_new_user: # a matching contact
- contacts[0].user = instance
- contacts[0].save()
-
- if len(contacts) > 1: # multiple matches
- logger.warning(
- "There are multiple Contacts with the same email address."
- f" Picking #{contacts[0].id} for User #{instance.id}."
- )
diff --git a/src/registrar/templates/django/admin/includes/contact_detail_list.html b/src/registrar/templates/django/admin/includes/contact_detail_list.html
index 3b49e62a4..2ee490d76 100644
--- a/src/registrar/templates/django/admin/includes/contact_detail_list.html
+++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html
@@ -12,37 +12,24 @@
{% if user.has_contact_info %}
{# Title #}
- {% if user.title or user.contact.title %}
- {% if user.contact.title %}
- {{ user.contact.title }}
- {% else %}
- {{ user.title }}
- {% endif %}
+ {% if user.title %}
+ {{ user.title }}
{% else %}
None
{% endif %}
{# Email #}
- {% if user.email or user.contact.email %}
- {% if user.contact.email %}
- {{ user.contact.email }}
- {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
- {% else %}
- {{ user.email }}
- {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
- {% endif %}
+ {% if user.email %}
+ {{ user.email }}
+ {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% else %}
None
{% endif %}
{# Phone #}
- {% if user.phone or user.contact.phone %}
- {% if user.contact.phone %}
- {{ user.contact.phone }}
- {% else %}
- {{ user.phone }}
- {% endif %}
+ {% if user.phone %}
+ {{ user.phone }}
{% else %}
None
diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html
index 06e0f4728..08137c094 100644
--- a/src/registrar/templates/domain_detail.html
+++ b/src/registrar/templates/domain_detail.html
@@ -62,7 +62,7 @@
{# Conditionally display profile #}
{% if not has_profile_feature_flag %}
{% url 'domain-your-contact-information' pk=domain.id as url %}
- {% include "includes/summary_item.html" with title='Your contact information' value=request.user.contact contact='true' edit_link=url editable=domain.is_editable %}
+ {% include "includes/summary_item.html" with title='Your contact information' value=request.user contact='true' edit_link=url editable=domain.is_editable %}
{% endif %}
{% url 'domain-security-email' pk=domain.id as url %}
diff --git a/src/registrar/templates/domain_request_intro.html b/src/registrar/templates/domain_request_intro.html
index 370ea2b2b..f4319f53d 100644
--- a/src/registrar/templates/domain_request_intro.html
+++ b/src/registrar/templates/domain_request_intro.html
@@ -18,7 +18,7 @@
completing your domain request might take around 15 minutes.
{% if has_profile_feature_flag %}
How we’ll reach you
-
While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review If the contact information below is not correct, visit your profile to make updates.
+
While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review. If the contact information below is not correct, visit your profile to make updates.
{% include "includes/profile_information.html" with user=user%}
{% endif %}
diff --git a/src/registrar/templates/domain_request_status.html b/src/registrar/templates/domain_request_status.html
index ad3dc4069..183a8be81 100644
--- a/src/registrar/templates/domain_request_status.html
+++ b/src/registrar/templates/domain_request_status.html
@@ -42,10 +42,13 @@
Last updated: {{DomainRequest.updated_at|date:"F j, Y"}}
diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py
index 4d13f0bb9..b005211a6 100644
--- a/src/registrar/tests/common.py
+++ b/src/registrar/tests/common.py
@@ -810,6 +810,8 @@ def create_superuser():
user = User.objects.create_user(
username="superuser",
email="admin@example.com",
+ first_name="first",
+ last_name="last",
is_staff=True,
password=p,
)
@@ -826,6 +828,8 @@ def create_user():
user = User.objects.create_user(
username="staffuser",
email="staff@example.com",
+ first_name="first",
+ last_name="last",
is_staff=True,
password=p,
)
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 957fb5509..dc76b44cd 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -44,6 +44,7 @@ from registrar.models import (
UserGroup,
TransitionDomain,
)
+from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.verified_by_staff import VerifiedByStaff
from .common import (
@@ -242,15 +243,11 @@ class TestDomainAdmin(MockEppLib, WebTest):
username="MrMeoward",
first_name="Meoward",
last_name="Jones",
+ email="meoward.jones@igorville.gov",
+ phone="(555) 123 12345",
+ title="Treat inspector",
)
- # Due to the relation between User <==> Contact,
- # the underlying contact has to be modified this way.
- _creator.contact.email = "meoward.jones@igorville.gov"
- _creator.contact.phone = "(555) 123 12345"
- _creator.contact.title = "Treat inspector"
- _creator.contact.save()
-
# Create a fake domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
domain_request.approve()
@@ -935,6 +932,32 @@ class TestDomainRequestAdmin(MockEppLib):
)
self.mock_client = MockSESClient()
+ def test_domain_request_senior_official_is_alphabetically_sorted(self):
+ """Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
+
+ SeniorOfficial.objects.get_or_create(first_name="mary", last_name="joe", title="some other guy")
+ SeniorOfficial.objects.get_or_create(first_name="alex", last_name="smoe", title="some guy")
+ SeniorOfficial.objects.get_or_create(first_name="Zoup", last_name="Soup", title="title")
+
+ contact, _ = Contact.objects.get_or_create(first_name="Henry", last_name="McFakerson")
+ domain_request = completed_domain_request(submitter=contact, name="city1.gov")
+ request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
+ model_admin = AuditedAdmin(DomainRequest, self.site)
+
+ # Get the queryset that would be returned for the list
+ senior_offical_queryset = model_admin.formfield_for_foreignkey(
+ DomainInformation.senior_official.field, request
+ ).queryset
+
+ # Make the list we're comparing on a bit prettier display-wise. Optional step.
+ current_sort_order = []
+ for official in senior_offical_queryset:
+ current_sort_order.append(f"{official.first_name} {official.last_name}")
+
+ expected_sort_order = ["alex smoe", "mary joe", "Zoup Soup"]
+
+ self.assertEqual(current_sort_order, expected_sort_order)
+
@less_console_noise_decorator
def test_has_model_description(self):
"""Tests if this model has a model description on the table view"""
@@ -2113,15 +2136,11 @@ class TestDomainRequestAdmin(MockEppLib):
username="MrMeoward",
first_name="Meoward",
last_name="Jones",
+ email="meoward.jones@igorville.gov",
+ phone="(555) 123 12345",
+ title="Treat inspector",
)
- # Due to the relation between User <==> Contact,
- # the underlying contact has to be modified this way.
- _creator.contact.email = "meoward.jones@igorville.gov"
- _creator.contact.phone = "(555) 123 12345"
- _creator.contact.title = "Treat inspector"
- _creator.contact.save()
-
# Create a fake domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
@@ -2138,11 +2157,11 @@ class TestDomainRequestAdmin(MockEppLib):
# == Check for the creator == #
- # Check for the right title, email, and phone number in the response.
+ # Check for the right title and phone number in the response.
+ # Email will appear more than once
expected_creator_fields = [
# Field, expected value
("title", "Treat inspector"),
- ("email", "meoward.jones@igorville.gov"),
("phone", "(555) 123 12345"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields)
@@ -2159,6 +2178,7 @@ class TestDomainRequestAdmin(MockEppLib):
]
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2")
+ self.assertContains(response, "meoward.jones@igorville.gov")
# == Check for the senior_official == #
self.assertContains(response, "testy@town.com", count=2)
@@ -2778,6 +2798,7 @@ class TestDomainRequestAdmin(MockEppLib):
User.objects.all().delete()
Contact.objects.all().delete()
Website.objects.all().delete()
+ SeniorOfficial.objects.all().delete()
self.mock_client.EMAILS_SENT.clear()
@@ -2959,6 +2980,38 @@ class TestDomainInformationAdmin(TestCase):
Domain.objects.all().delete()
Contact.objects.all().delete()
User.objects.all().delete()
+ SeniorOfficial.objects.all().delete()
+
+ def test_domain_information_senior_official_is_alphabetically_sorted(self):
+ """Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
+
+ SeniorOfficial.objects.get_or_create(first_name="mary", last_name="joe", title="some other guy")
+ SeniorOfficial.objects.get_or_create(first_name="alex", last_name="smoe", title="some guy")
+ SeniorOfficial.objects.get_or_create(first_name="Zoup", last_name="Soup", title="title")
+
+ contact, _ = Contact.objects.get_or_create(first_name="Henry", last_name="McFakerson")
+ domain_request = completed_domain_request(
+ submitter=contact, name="city1244.gov", status=DomainRequest.DomainRequestStatus.IN_REVIEW
+ )
+ domain_request.approve()
+
+ domain_info = DomainInformation.objects.get(domain_request=domain_request)
+ request = self.factory.post("/admin/registrar/domaininformation/{}/change/".format(domain_info.pk))
+ model_admin = AuditedAdmin(DomainInformation, self.site)
+
+ # Get the queryset that would be returned for the list
+ senior_offical_queryset = model_admin.formfield_for_foreignkey(
+ DomainInformation.senior_official.field, request
+ ).queryset
+
+ # Make the list we're comparing on a bit prettier display-wise. Optional step.
+ current_sort_order = []
+ for official in senior_offical_queryset:
+ current_sort_order.append(f"{official.first_name} {official.last_name}")
+
+ expected_sort_order = ["alex smoe", "mary joe", "Zoup Soup"]
+
+ self.assertEqual(current_sort_order, expected_sort_order)
@less_console_noise_decorator
def test_admin_can_see_cisa_region_federal(self):
@@ -3155,15 +3208,11 @@ class TestDomainInformationAdmin(TestCase):
username="MrMeoward",
first_name="Meoward",
last_name="Jones",
+ email="meoward.jones@igorville.gov",
+ phone="(555) 123 12345",
+ title="Treat inspector",
)
- # Due to the relation between User <==> Contact,
- # the underlying contact has to be modified this way.
- _creator.contact.email = "meoward.jones@igorville.gov"
- _creator.contact.phone = "(555) 123 12345"
- _creator.contact.title = "Treat inspector"
- _creator.contact.save()
-
# Create a fake domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
domain_request.approve()
@@ -3185,16 +3234,16 @@ class TestDomainInformationAdmin(TestCase):
# == Check for the creator == #
- # Check for the right title, email, and phone number in the response.
+ # Check for the right title and phone number in the response.
# We only need to check for the end tag
# (Otherwise this test will fail if we change classes, etc)
expected_creator_fields = [
# Field, expected value
("title", "Treat inspector"),
- ("email", "meoward.jones@igorville.gov"),
("phone", "(555) 123 12345"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields)
+ self.assertContains(response, "meoward.jones@igorville.gov")
# Check for the field itself
self.assertContains(response, "Meoward Jones")
@@ -3713,6 +3762,7 @@ class AuditedAdminTest(TestCase):
self.site = AdminSite()
self.factory = RequestFactory()
self.client = Client(HTTP_HOST="localhost:8080")
+ self.staffuser = create_user()
def order_by_desired_field_helper(self, obj_to_sort: AuditedAdmin, request, field_name, *obj_names):
with less_console_noise():
@@ -3764,7 +3814,9 @@ class AuditedAdminTest(TestCase):
def test_alphabetically_sorted_fk_fields_domain_request(self):
with less_console_noise():
tested_fields = [
- DomainRequest.senior_official.field,
+ # Senior offical is commented out for now - this is alphabetized
+ # and this test does not accurately reflect that.
+ # DomainRequest.senior_official.field,
DomainRequest.submitter.field,
# DomainRequest.investigator.field,
DomainRequest.creator.field,
@@ -3822,7 +3874,9 @@ class AuditedAdminTest(TestCase):
def test_alphabetically_sorted_fk_fields_domain_information(self):
with less_console_noise():
tested_fields = [
- DomainInformation.senior_official.field,
+ # Senior offical is commented out for now - this is alphabetized
+ # and this test does not accurately reflect that.
+ # DomainInformation.senior_official.field,
DomainInformation.submitter.field,
# DomainInformation.creator.field,
(DomainInformation.domain.field, ["name"]),
@@ -3855,7 +3909,6 @@ class AuditedAdminTest(TestCase):
# Conforms to the same object structure as desired_order
current_sort_order_coerced_type = []
-
# This is necessary as .queryset and get_queryset
# return lists of different types/structures.
# We need to parse this data and coerce them into the same type.
@@ -3932,7 +3985,8 @@ class AuditedAdminTest(TestCase):
if last_name is None:
return (first_name,)
- if first_name.split(queryset_shorthand)[1] == field_name:
+ split_name = first_name.split(queryset_shorthand)
+ if len(split_name) == 2 and split_name[1] == field_name:
return returned_tuple
else:
return None
@@ -4101,7 +4155,7 @@ class TestContactAdmin(TestCase):
readonly_fields = self.admin.get_readonly_fields(request)
- expected_fields = ["user", "email"]
+ expected_fields = ["email"]
self.assertEqual(readonly_fields, expected_fields)
@@ -4117,15 +4171,18 @@ class TestContactAdmin(TestCase):
self.assertEqual(readonly_fields, expected_fields)
def test_change_view_for_joined_contact_five_or_less(self):
- """Create a contact, join it to 4 domain requests. The 5th join will be a user.
- Assert that the warning on the contact form lists 5 joins."""
+ """Create a contact, join it to 4 domain requests.
+ Assert that the warning on the contact form lists 4 joins."""
with less_console_noise():
self.client.force_login(self.superuser)
# Create an instance of the model
- contact, _ = Contact.objects.get_or_create(user=self.staffuser)
+ contact, _ = Contact.objects.get_or_create(
+ first_name="Henry",
+ last_name="McFakerson",
+ )
- # join it to 4 domain requests. The 5th join will be a user.
+ # join it to 4 domain requests.
domain_request1 = completed_domain_request(submitter=contact, name="city1.gov")
domain_request2 = completed_domain_request(submitter=contact, name="city2.gov")
domain_request3 = completed_domain_request(submitter=contact, name="city3.gov")
@@ -4148,24 +4205,26 @@ class TestContactAdmin(TestCase):
f"domainrequest/{domain_request3.pk}/change/'>city3.gov"
"