diff --git a/docs/developer/README.md b/docs/developer/README.md index a06202d8d..f894955e5 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -250,7 +250,7 @@ type docker-compose run owasp ``` -# Images, stylesheets, and JavaScript +## Images, stylesheets, and JavaScript We use the U.S. Web Design System (USWDS) for styling our applications. @@ -262,7 +262,7 @@ Assets are stored in `registrar/assets` during development and served from `regi We utilize the [uswds-compile tool](https://designsystem.digital.gov/documentation/getting-started/developers/phase-two-compile/) from USWDS to compile and package USWDS assets. -## Making and viewing style changes +### Making and viewing style changes When you run `docker-compose up` the `node` service in the container will begin to watch for changes in the `registrar/assets` folder, and will recompile once any changes are made. @@ -273,7 +273,11 @@ Within the `registrar/assets` folder, the `_theme` folder contains three files i You can also compile the **Sass** at any time using `npx gulp compile`. Similarly, you can copy over **other static assets** (images and javascript files), using `npx gulp copyAssets`. -## Upgrading USWDS and other JavaScript packages +### CSS class naming conventions + +We use the [CSS Block Element Modifier (BEM)](https://getbem.com/naming/) naming convention for our custom classes. This is in line with how USWDS [approaches](https://designsystem.digital.gov/whats-new/updates/2019/04/08/introducing-uswds-2-0/) their CSS class architecture and helps keep our code cohesive and readable. + +### Upgrading USWDS and other JavaScript packages Version numbers can be manually controlled in `package.json`. Edit that, if desired. diff --git a/src/djangooidc/exceptions.py b/src/djangooidc/exceptions.py index 226337f54..000c47649 100644 --- a/src/djangooidc/exceptions.py +++ b/src/djangooidc/exceptions.py @@ -33,8 +33,8 @@ class AuthenticationFailed(OIDCException): friendly_message = "This login attempt didn't work." -class NoStateDefined(OIDCException): - friendly_message = "The session state is None." +class StateMismatch(AuthenticationFailed): + friendly_message = "State mismatch. This login attempt didn't work." class InternalError(OIDCException): diff --git a/src/djangooidc/oidc.py b/src/djangooidc/oidc.py index bff766bb4..95ed322f5 100644 --- a/src/djangooidc/oidc.py +++ b/src/djangooidc/oidc.py @@ -182,10 +182,20 @@ class Client(oic.Client): if authn_response["state"] != session.get("state", None): # this most likely means the user's Django session vanished - logger.error("Received state not the same as expected for %s" % state) if session.get("state", None) is None: - raise o_e.NoStateDefined() - raise o_e.AuthenticationFailed(locator=state) + logger.error( + f"The OP state {state} does not match the session state. " + f"The session state is None. " + f"authn_response['state'] = {authn_response['state']} " + f"session.get('state', None) = {session.get('state', None)}" + ) + else: + logger.error( + f"The OP state {state} does not match the session state. " + f"authn_response['state'] = {authn_response['state']} " + f"session.get('state', None) = {session.get('state', None)}" + ) + raise o_e.StateMismatch() if self.behaviour.get("response_type") == "code": # need an access token to get user info (and to log the user out later) diff --git a/src/djangooidc/tests/test_views.py b/src/djangooidc/tests/test_views.py index 0f734b80d..f10afcbaf 100644 --- a/src/djangooidc/tests/test_views.py +++ b/src/djangooidc/tests/test_views.py @@ -4,7 +4,7 @@ from django.http import HttpResponse from django.test import Client, TestCase, RequestFactory from django.urls import reverse -from djangooidc.exceptions import NoStateDefined, InternalError +from djangooidc.exceptions import StateMismatch, InternalError from ..views import login_callback from .common import less_console_noise @@ -129,21 +129,35 @@ class ViewsTest(TestCase): self.assertContains(response, "Hi") def test_login_callback_with_no_session_state(self, mock_client): - """If the local session is None (ie the server restarted while user was logged out), + """If the local session does not match the OP session, we do not throw an exception. Rather, we attempt to login again.""" with less_console_noise(): - # MOCK - # mock the acr_value to some string - # mock the callback function to raise the NoStateDefined Exception + # MOCK get_default_acr_value and the callback to raise StateMismatch + # error when called mock_client.get_default_acr_value.side_effect = self.create_acr - mock_client.callback.side_effect = NoStateDefined() - # TEST - # test the login callback + mock_client.callback.side_effect = StateMismatch() + # TEST receiving a response from login.gov response = self.client.get(reverse("openid_login_callback")) - # ASSERTIONS - # assert that the user is redirected to the start of the login process + # ASSERT self.assertEqual(response.status_code, 302) self.assertEqual(response.url, "/") + # Check that the redirect_attempted flag is set in the session + self.assertTrue(self.client.session.get("redirect_attempted", False)) + + def test_login_callback_with_no_session_state_attempt_again_only_once(self, mock_client): + """We only attempt to relogin once. After that, it's the error page for you.""" + with less_console_noise(): + # MOCK get_default_acr_value, redirect_attempted to True and the callback + # to raise StateMismatch error when called + mock_client.get_default_acr_value.side_effect = self.create_acr + mock_client.callback.side_effect = StateMismatch() + session = self.client.session + session["redirect_attempted"] = True + session.save() + # TEST receiving a response from login.gov + response = self.client.get(reverse("openid_login_callback")) + # ASSERT + self.assertEqual(response.status_code, 401) def test_login_callback_reads_next(self, mock_client): """If the next value is set in the session, test that login_callback returns diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index 8e112769b..ab81ccff1 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -101,16 +101,25 @@ def login_callback(request): if user: login(request, user) logger.info("Successfully logged in user %s" % user) - # Double login bug (1507)? + # Clear the flag if the exception is not caught + request.session.pop("redirect_attempted", None) return redirect(request.session.get("next", "/")) else: raise o_e.BannedUser() - except o_e.NoStateDefined as nsd_err: - # In the event that a user is in the middle of a login when the app is restarted, - # their session state will no longer be available, so redirect the user to the - # beginning of login process without raising an error to the user. - logger.warning(f"No State Defined: {nsd_err}") - return redirect(request.session.get("next", "/")) + except o_e.StateMismatch as nsd_err: + # Check if the redirect has already been attempted + if not request.session.get("redirect_attempted", False): + # Set the flag to indicate that the redirect has been attempted + request.session["redirect_attempted"] = True + + # In the event of a state mismatch between OP and session, redirect the user to the + # beginning of login process without raising an error to the user. Attempt once. + logger.warning(f"No State Defined: {nsd_err}") + return redirect(request.session.get("next", "/")) + else: + # Clear the flag if the exception is not caught + request.session.pop("redirect_attempted", None) + return error_page(request, nsd_err) except Exception as err: return error_page(request, err) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index f18fdd26a..6d1f588fb 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -16,7 +16,7 @@ from django.urls import reverse from dateutil.relativedelta import relativedelta # type: ignore from epplibwrapper.errors import ErrorCode, RegistryError from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website -from registrar.utility.errors import FSMApplicationError, FSMErrorCodes +from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from registrar.views.utility.mixins import OrderableFieldsMixin from django.contrib.admin.views.main import ORDER_VAR from registrar.widgets import NoAutocompleteFilteredSelectMultiple @@ -133,6 +133,7 @@ class DomainRequestAdminForm(forms.ModelForm): cleaned_data = super().clean() status = cleaned_data.get("status") investigator = cleaned_data.get("investigator") + rejection_reason = cleaned_data.get("rejection_reason") # Get the old status initial_status = self.initial.get("status", None) @@ -153,8 +154,32 @@ class DomainRequestAdminForm(forms.ModelForm): # Will call "add_error" if any issues are found. self._check_for_valid_investigator(investigator) + # If the status is rejected, a rejection reason must exist + if status == DomainRequest.DomainRequestStatus.REJECTED: + self._check_for_valid_rejection_reason(rejection_reason) + return cleaned_data + def _check_for_valid_rejection_reason(self, rejection_reason) -> bool: + """ + Checks if the rejection_reason field is not none. + Adds form errors on failure. + """ + is_valid = False + + # Check if a rejection reason exists. Rejection is not possible without one. + error_message = None + if rejection_reason is None or rejection_reason == "": + # Lets grab the error message from a common location + error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_REJECTION_REASON) + else: + is_valid = True + + if error_message is not None: + self.add_error("rejection_reason", error_message) + + return is_valid + def _check_for_valid_investigator(self, investigator) -> bool: """ Checks if the investigator field is not none, and is staff. @@ -167,9 +192,9 @@ class DomainRequestAdminForm(forms.ModelForm): error_message = None if investigator is None: # Lets grab the error message from a common location - error_message = FSMApplicationError.get_error_message(FSMErrorCodes.NO_INVESTIGATOR) + error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_INVESTIGATOR) elif not investigator.is_staff: - error_message = FSMApplicationError.get_error_message(FSMErrorCodes.INVESTIGATOR_NOT_STAFF) + error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.INVESTIGATOR_NOT_STAFF) else: is_valid = True @@ -845,7 +870,7 @@ class DomainInformationAdmin(ListHeaderAdmin): # Columns list_display = [ "domain", - "organization_type", + "generic_org_type", "created_at", "submitter", ] @@ -856,7 +881,7 @@ class DomainInformationAdmin(ListHeaderAdmin): ] # Filters - list_filter = ["organization_type"] + list_filter = ["generic_org_type"] # Search search_fields = [ @@ -873,7 +898,7 @@ class DomainInformationAdmin(ListHeaderAdmin): "Type of organization", { "fields": [ - "organization_type", + "generic_org_type", "is_election_board", "federal_type", "federal_agency", @@ -1012,13 +1037,13 @@ class DomainRequestAdmin(ListHeaderAdmin): if self.value() == "0": return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) - change_form_template = "django/admin/domain_application_change_form.html" + change_form_template = "django/admin/domain_request_change_form.html" # Columns list_display = [ "requested_domain", "status", - "organization_type", + "generic_org_type", "federal_type", "federal_agency", "organization_name", @@ -1045,7 +1070,7 @@ class DomainRequestAdmin(ListHeaderAdmin): # Filters list_filter = ( "status", - "organization_type", + "generic_org_type", "federal_type", ElectionOfficeFilter, "rejection_reason", @@ -1083,7 +1108,7 @@ class DomainRequestAdmin(ListHeaderAdmin): "Type of organization", { "fields": [ - "organization_type", + "generic_org_type", "is_election_board", "federal_type", "federal_agency", @@ -1231,7 +1256,7 @@ class DomainRequestAdmin(ListHeaderAdmin): # This condition should never be triggered. # The opposite of this condition is acceptable (rejected -> other status and rejection_reason) # because we clean up the rejection reason in the transition in the model. - error_message = "A rejection reason is required." + error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_REJECTION_REASON) else: # This is an fsm in model which will throw an error if the # transition condition is violated, so we roll back the @@ -1240,11 +1265,11 @@ class DomainRequestAdmin(ListHeaderAdmin): obj.status = original_obj.status # Try to perform the status change. - # Catch FSMApplicationError's and return the message, + # Catch FSMDomainRequestError's and return the message, # as these are typically user errors. try: selected_method() - except FSMApplicationError as err: + except FSMDomainRequestError as err: logger.warning(f"An error encountered when trying to change status: {err}") error_message = err.message @@ -1418,7 +1443,7 @@ class DomainAdmin(ListHeaderAdmin): # Columns list_display = [ "name", - "organization_type", + "generic_org_type", "federal_type", "federal_agency", "organization_name", @@ -1443,10 +1468,10 @@ class DomainAdmin(ListHeaderAdmin): # in autocomplete_fields for domain ordering = ["name"] - def organization_type(self, obj): - return obj.domain_info.get_organization_type_display() + def generic_org_type(self, obj): + return obj.domain_info.get_generic_org_type_display() - organization_type.admin_order_field = "domain_info__organization_type" # type: ignore + generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore def federal_agency(self, obj): return obj.domain_info.federal_agency if obj.domain_info else None @@ -1483,7 +1508,7 @@ class DomainAdmin(ListHeaderAdmin): state_territory.admin_order_field = "domain_info__state_territory" # type: ignore # Filters - list_filter = ["domain_info__organization_type", "domain_info__federal_type", ElectionOfficeFilter, "state"] + list_filter = ["domain_info__generic_org_type", "domain_info__federal_type", ElectionOfficeFilter, "state"] search_fields = ["name"] search_help_text = "Search by domain name." @@ -1822,6 +1847,13 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): super().save_model(request, obj, form, change) +class FederalAgencyAdmin(ListHeaderAdmin): + list_display = ["agency"] + search_fields = ["agency"] + search_help_text = "Search by agency name." + ordering = ["agency"] + + admin.site.unregister(LogEntry) # Unregister the default registration admin.site.register(LogEntry, CustomLogEntryAdmin) admin.site.register(models.User, MyUserAdmin) @@ -1835,6 +1867,7 @@ admin.site.register(models.DomainInvitation, DomainInvitationAdmin) admin.site.register(models.DomainInformation, DomainInformationAdmin) admin.site.register(models.Domain, DomainAdmin) admin.site.register(models.DraftDomain, DraftDomainAdmin) +admin.site.register(models.FederalAgency, FederalAgencyAdmin) # Host and HostIP removed from django admin because changes in admin # do not propagate to registry and logic not applied admin.site.register(models.Host, MyHostAdmin) diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index a4a9fca54..34e72929f 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -347,6 +347,32 @@ input.admin-confirm-button { color: $dhs-blue-70; } +.admin-icon-group { + position: relative; + display: flex; + align-items: center; + + .usa-button__icon { + position: absolute; + right: 0; + height: 100%; + } + + input { + // Allow for padding around the copy button + padding-right: 35px !important; + // Match the height of other inputs + min-height: 2.25rem !important; + } + +} + +td.font-size-sm { + button.usa-button__icon { + font-size: 16px; + } +} + details.dja-detail-table { background-color: var(--darkened-bg); display: inline-table; @@ -397,3 +423,7 @@ td.font-size-sm { .no-outline-on-click:focus { outline: none !important; } + +.errors span.select2-selection { + border: 1px solid var(--error-fg) !important; +} \ No newline at end of file diff --git a/src/registrar/fixtures_domain_requests.py b/src/registrar/fixtures_domain_requests.py index a37e29d6b..02efae5a9 100644 --- a/src/registrar/fixtures_domain_requests.py +++ b/src/registrar/fixtures_domain_requests.py @@ -30,7 +30,7 @@ class DomainRequestFixture: # { # "status": "started", # "organization_name": "Example - Just started", - # "organization_type": "federal", + # "generic_org_type": "federal", # "federal_agency": None, # "federal_type": None, # "address_line1": None, @@ -98,7 +98,7 @@ class DomainRequestFixture: def _set_non_foreign_key_fields(cls, da: DomainRequest, app: dict): """Helper method used by `load`.""" da.status = app["status"] if "status" in app else "started" - da.organization_type = app["organization_type"] if "organization_type" in app else "federal" + da.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal" da.federal_agency = ( app["federal_agency"] if "federal_agency" in app diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 22d76c768..0bfd9b667 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -262,8 +262,8 @@ class AuthorizingOfficialContactForm(ContactForm): return super().save() # Determine if the domain is federal or tribal - is_federal = self.domainInfo.organization_type == DomainRequest.OrganizationChoices.FEDERAL - is_tribal = self.domainInfo.organization_type == DomainRequest.OrganizationChoices.TRIBAL + is_federal = self.domainInfo.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL + is_tribal = self.domainInfo.generic_org_type == DomainRequest.OrganizationChoices.TRIBAL # Get the Contact object from the db for the Authorizing Official db_ao = Contact.objects.get(id=self.instance.id) @@ -363,8 +363,8 @@ class DomainOrgNameAddressForm(forms.ModelForm): self.fields["state_territory"].widget.attrs.pop("maxlength", None) self.fields["zipcode"].widget.attrs.pop("maxlength", None) - self.is_federal = self.instance.organization_type == DomainRequest.OrganizationChoices.FEDERAL - self.is_tribal = self.instance.organization_type == DomainRequest.OrganizationChoices.TRIBAL + self.is_federal = self.instance.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL + self.is_tribal = self.instance.generic_org_type == DomainRequest.OrganizationChoices.TRIBAL field_to_disable = None if self.is_federal: @@ -384,9 +384,9 @@ class DomainOrgNameAddressForm(forms.ModelForm): # If they get past this point, we forbid it this way. # This could be malicious, so lets reserve information for the backend only. if self.is_federal and not self._field_unchanged("federal_agency"): - raise ValueError("federal_agency cannot be modified when the organization_type is federal") + raise ValueError("federal_agency cannot be modified when the generic_org_type is federal") elif self.is_tribal and not self._field_unchanged("organization_name"): - raise ValueError("organization_name cannot be modified when the organization_type is tribal") + raise ValueError("organization_name cannot be modified when the generic_org_type is tribal") else: super().save() diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index ef47143ea..1efc028f6 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -169,7 +169,7 @@ class RegistrarFormSet(forms.BaseFormSet): class OrganizationTypeForm(RegistrarForm): - organization_type = forms.ChoiceField( + generic_org_type = forms.ChoiceField( # use the long names in the domain request form choices=DomainRequest.OrganizationChoicesVerbose.choices, widget=forms.RadioSelect, diff --git a/src/registrar/management/commands/transfer_transition_domains_to_domains.py b/src/registrar/management/commands/transfer_transition_domains_to_domains.py index 7f09d8de7..4ea74e335 100644 --- a/src/registrar/management/commands/transfer_transition_domains_to_domains.py +++ b/src/registrar/management/commands/transfer_transition_domains_to_domains.py @@ -332,7 +332,7 @@ class Command(BaseCommand): updated = False fields_to_update = [ - "organization_type", + "generic_org_type", "federal_type", "federal_agency", "organization_name", @@ -400,7 +400,7 @@ class Command(BaseCommand): if debug_on: logger.info(f"Contact created: {contact}") - org_type_current = transition_domain.organization_type + org_type_current = transition_domain.generic_org_type match org_type_current: case "Federal": org_type = ("federal", "Federal") @@ -431,7 +431,7 @@ class Command(BaseCommand): } if valid_org_type: - new_domain_info_data["organization_type"] = org_type[0] + new_domain_info_data["generic_org_type"] = org_type[0] elif debug_on: logger.debug(f"No org type found on {domain.name}") diff --git a/src/registrar/management/commands/utility/extra_transition_domain_helper.py b/src/registrar/management/commands/utility/extra_transition_domain_helper.py index 5c3573fb1..eb41c4be8 100644 --- a/src/registrar/management/commands/utility/extra_transition_domain_helper.py +++ b/src/registrar/management/commands/utility/extra_transition_domain_helper.py @@ -200,7 +200,7 @@ class LoadExtraTransitionDomain: updated_fields = [ "organization_name", - "organization_type", + "generic_org_type", "federal_type", "federal_agency", "first_name", @@ -412,7 +412,7 @@ class LoadExtraTransitionDomain: return transition_domain def parse_domain_type_data(self, domain_name, transition_domain: TransitionDomain) -> TransitionDomain: - """Grabs organization_type and federal_type from the parsed files + """Grabs generic_org_type and federal_type from the parsed files and associates it with a transition_domain object, then returns that object.""" if not isinstance(transition_domain, TransitionDomain): raise ValueError("Not a valid object, must be TransitionDomain") @@ -439,7 +439,7 @@ class LoadExtraTransitionDomain: raise ValueError("Found invalid data on DOMAIN_ADHOC") # Then, just grab the organization type. - new_organization_type = domain_type[0].strip() + new_generic_org_type = domain_type[0].strip() # Check if this domain_type is active or not. # If not, we don't want to add this. @@ -455,8 +455,8 @@ class LoadExtraTransitionDomain: # Are we updating data that already exists, # or are we adding new data in its place? - organization_type_exists = ( - transition_domain.organization_type is not None and transition_domain.organization_type.strip() != "" + generic_org_type_exists = ( + transition_domain.generic_org_type is not None and transition_domain.generic_org_type.strip() != "" ) federal_type_exists = ( transition_domain.federal_type is not None and transition_domain.federal_type.strip() != "" @@ -467,20 +467,20 @@ class LoadExtraTransitionDomain: is_federal = domain_type_length == 2 if is_federal: new_federal_type = domain_type[1].strip() - transition_domain.organization_type = new_organization_type + transition_domain.generic_org_type = new_generic_org_type transition_domain.federal_type = new_federal_type else: - transition_domain.organization_type = new_organization_type + transition_domain.generic_org_type = new_generic_org_type transition_domain.federal_type = None # Logs if we either added to this property, # or modified it. self._add_or_change_message( EnumFilenames.DOMAIN_ADHOC, - "organization_type", - transition_domain.organization_type, + "generic_org_type", + transition_domain.generic_org_type, domain_name, - organization_type_exists, + generic_org_type_exists, ) self._add_or_change_message( diff --git a/src/registrar/migrations/0078_rename_organization_type_domaininformation_generic_org_type_and_more.py b/src/registrar/migrations/0078_rename_organization_type_domaininformation_generic_org_type_and_more.py new file mode 100644 index 000000000..fb9d65cce --- /dev/null +++ b/src/registrar/migrations/0078_rename_organization_type_domaininformation_generic_org_type_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.10 on 2024-03-20 21:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0077_alter_publiccontact_fax_alter_publiccontact_org_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="domaininformation", + old_name="organization_type", + new_name="generic_org_type", + ), + migrations.RenameField( + model_name="domainrequest", + old_name="organization_type", + new_name="generic_org_type", + ), + migrations.RenameField( + model_name="transitiondomain", + old_name="organization_type", + new_name="generic_org_type", + ), + ] diff --git a/src/registrar/migrations/0079_create_federal_agencies_v01.py b/src/registrar/migrations/0079_create_federal_agencies_v01.py new file mode 100644 index 000000000..2f42e3382 --- /dev/null +++ b/src/registrar/migrations/0079_create_federal_agencies_v01.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.10 on 2024-03-22 22:18 + +from django.db import migrations, models +from registrar.models import FederalAgency +from typing import Any + + +# For linting: RunPython expects a function reference. +def create_federal_agencies(apps, schema_editor) -> Any: + FederalAgency.create_federal_agencies(apps, schema_editor) + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0078_rename_organization_type_domaininformation_generic_org_type_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="FederalAgency", + 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)), + ("agency", models.CharField(blank=True, help_text="Federal agency", null=True)), + ], + options={ + "verbose_name": "Federal agency", + "verbose_name_plural": "Federal agencies", + }, + ), + migrations.RunPython( + create_federal_agencies, + reverse_code=migrations.RunPython.noop, + atomic=True, + ), + ] diff --git a/src/registrar/migrations/0080_create_groups_v09.py b/src/registrar/migrations/0080_create_groups_v09.py new file mode 100644 index 000000000..342404aa1 --- /dev/null +++ b/src/registrar/migrations/0080_create_groups_v09.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", "0079_create_federal_agencies_v01"), + ] + + 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 d203421ac..d3bbb3ae5 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -4,6 +4,7 @@ from .domain_request import DomainRequest from .domain_information import DomainInformation from .domain import Domain from .draft_domain import DraftDomain +from .federal_agency import FederalAgency from .host_ip import HostIP from .host import Host from .domain_invitation import DomainInvitation @@ -22,6 +23,7 @@ __all__ = [ "Domain", "DraftDomain", "DomainInvitation", + "FederalAgency", "HostIP", "Host", "UserDomainRole", @@ -39,6 +41,7 @@ auditlog.register(Domain) auditlog.register(DraftDomain) auditlog.register(DomainInvitation) auditlog.register(DomainInformation) +auditlog.register(FederalAgency) auditlog.register(HostIP) auditlog.register(Host) auditlog.register(UserDomainRole) diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index f8f4db9c6..b5755a3c9 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -49,7 +49,7 @@ class DomainInformation(TimeStampedModel): ) # ##### data fields from the initial form ##### - organization_type = models.CharField( + generic_org_type = models.CharField( max_length=255, choices=OrganizationChoices.choices, null=True, diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index e7378a880..f4581de93 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -9,7 +9,7 @@ from django.db import models from django_fsm import FSMField, transition # type: ignore from django.utils import timezone from registrar.models.domain import Domain -from registrar.utility.errors import FSMApplicationError, FSMErrorCodes +from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from .utility.time_stamped_model import TimeStampedModel from ..utility.email import send_templated_email, EmailSendingError @@ -397,7 +397,7 @@ class DomainRequest(TimeStampedModel): ) # ##### data fields from the initial form ##### - organization_type = models.CharField( + generic_org_type = models.CharField( max_length=255, # use the short names in Django admin choices=OrganizationChoices.choices, @@ -791,7 +791,7 @@ class DomainRequest(TimeStampedModel): # == Check that the domain_request is valid == # if Domain.objects.filter(name=self.requested_domain.name).exists(): - raise FSMApplicationError(code=FSMErrorCodes.APPROVE_DOMAIN_IN_USE) + raise FSMDomainRequestError(code=FSMErrorCodes.APPROVE_DOMAIN_IN_USE) # == Create the domain and related components == # created_domain = Domain.objects.create(name=self.requested_domain.name) @@ -886,12 +886,12 @@ class DomainRequest(TimeStampedModel): def show_organization_federal(self) -> bool: """Show this step if the answer to the first question was "federal".""" - user_choice = self.organization_type + user_choice = self.generic_org_type return user_choice == DomainRequest.OrganizationChoices.FEDERAL def show_tribal_government(self) -> bool: """Show this step if the answer to the first question was "tribal".""" - user_choice = self.organization_type + user_choice = self.generic_org_type return user_choice == DomainRequest.OrganizationChoices.TRIBAL def show_organization_election(self) -> bool: @@ -900,7 +900,7 @@ class DomainRequest(TimeStampedModel): This shows for answers that aren't "Federal" or "Interstate". This also doesnt show if user selected "School District" as well (#524) """ - user_choice = self.organization_type + user_choice = self.generic_org_type excluded = [ DomainRequest.OrganizationChoices.FEDERAL, DomainRequest.OrganizationChoices.INTERSTATE, @@ -910,7 +910,7 @@ class DomainRequest(TimeStampedModel): def show_about_your_organization(self) -> bool: """Show this step if this is a special district or interstate.""" - user_choice = self.organization_type + user_choice = self.generic_org_type return user_choice in [ DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, DomainRequest.OrganizationChoices.INTERSTATE, @@ -927,12 +927,12 @@ class DomainRequest(TimeStampedModel): def is_federal(self) -> Union[bool, None]: """Is this domain request for a federal agency? - organization_type can be both null and blank, + generic_org_type can be both null and blank, """ - if not self.organization_type: - # organization_type is either blank or None, can't answer + if not self.generic_org_type: + # generic_org_type is either blank or None, can't answer return None - if self.organization_type == DomainRequest.OrganizationChoices.FEDERAL: + if self.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL: return True return False diff --git a/src/registrar/models/federal_agency.py b/src/registrar/models/federal_agency.py new file mode 100644 index 000000000..89b15ab56 --- /dev/null +++ b/src/registrar/models/federal_agency.py @@ -0,0 +1,223 @@ +from .utility.time_stamped_model import TimeStampedModel +from django.db import models +import logging + +logger = logging.getLogger(__name__) + + +class FederalAgency(TimeStampedModel): + class Meta: + verbose_name = "Federal agency" + verbose_name_plural = "Federal agencies" + + agency = models.CharField( + null=True, + blank=True, + help_text="Federal agency", + ) + + def __str__(self) -> str: + return f"{self.agency}" + + def create_federal_agencies(apps, schema_editor): + """This method gets run from a data migration to prepopulate data + regarding federal agencies.""" + + # Hard to pass self to these methods as the calls from migrations + # are only expecting apps and schema_editor, so we'll just define + # apps, schema_editor in the local scope instead + + AGENCIES = [ + "Administrative Conference of the United States", + "Advisory Council on Historic Preservation", + "American Battle Monuments Commission", + "AMTRAK", + "Appalachian Regional Commission", + ("Appraisal Subcommittee of the Federal Financial " "Institutions Examination Council"), + "Architect of the Capitol", + "Armed Forces Retirement Home", + "Barry Goldwater Scholarship and Excellence in Education Foundation", + "Central Intelligence Agency", + "Christopher Columbus Fellowship Foundation", + "Civil Rights Cold Case Records Review Board", + "Commission for the Preservation of America's Heritage Abroad", + "Commission of Fine Arts", + "Committee for Purchase From People Who Are Blind or Severely Disabled", + "Commodity Futures Trading Commission", + "Congressional Budget Office", + "Consumer Financial Protection Bureau", + "Consumer Product Safety Commission", + "Corporation for National and Community Service", + "Council of Inspectors General on Integrity and Efficiency", + "Court Services and Offender Supervision", + "Cyberspace Solarium Commission", + "DC Court Services and Offender Supervision Agency", + "DC Pre-trial Services", + "Defense Nuclear Facilities Safety Board", + "Delta Regional Authority", + "Denali Commission", + "Department of Agriculture", + "Department of Commerce", + "Department of Defense", + "Department of Education", + "Department of Energy", + "Department of Health and Human Services", + "Department of Homeland Security", + "Department of Housing and Urban Development", + "Department of Justice", + "Department of Labor", + "Department of State", + "Department of the Interior", + "Department of the Treasury", + "Department of Transportation", + "Department of Veterans Affairs", + "Director of National Intelligence", + "Dwight D. Eisenhower Memorial Commission", + "Election Assistance Commission", + "Environmental Protection Agency", + "Equal Employment Opportunity Commission", + "Executive Office of the President", + "Export-Import Bank of the United States", + "Farm Credit Administration", + "Farm Credit System Insurance Corporation", + "Federal Communications Commission", + "Federal Deposit Insurance Corporation", + "Federal Election Commission", + "Federal Energy Regulatory Commission", + "Federal Financial Institutions Examination Council", + "Federal Housing Finance Agency", + "Federal Judiciary", + "Federal Labor Relations Authority", + "Federal Maritime Commission", + "Federal Mediation and Conciliation Service", + "Federal Mine Safety and Health Review Commission", + "Federal Permitting Improvement Steering Council", + "Federal Reserve Board of Governors", + "Federal Trade Commission", + "General Services Administration", + "gov Administration", + "Government Accountability Office", + "Government Publishing Office", + "Gulf Coast Ecosystem Restoration Council", + "Harry S. Truman Scholarship Foundation", + "Institute of Museum and Library Services", + "Institute of Peace", + "Inter-American Foundation", + "International Boundary and Water Commission: United States and Mexico", + "International Boundary Commission: United States and Canada", + "International Joint Commission: United States and Canada", + "James Madison Memorial Fellowship Foundation", + "Japan-U.S. Friendship Commission", + "John F. Kennedy Center for the Performing Arts", + "Legal Services Corporation", + "Legislative Branch", + "Library of Congress", + "Marine Mammal Commission", + "Medicaid and CHIP Payment and Access Commission", + "Medicare Payment Advisory Commission", + "Merit Systems Protection Board", + "Millennium Challenge Corporation", + "Morris K. Udall and Stewart L. Udall Foundation", + "National Aeronautics and Space Administration", + "National Archives and Records Administration", + "National Capital Planning Commission", + "National Council on Disability", + "National Credit Union Administration", + "National Endowment for the Arts", + "National Endowment for the Humanities", + "National Foundation on the Arts and the Humanities", + "National Gallery of Art", + "National Indian Gaming Commission", + "National Labor Relations Board", + "National Mediation Board", + "National Science Foundation", + "National Security Commission on Artificial Intelligence", + "National Transportation Safety Board", + "Networking Information Technology Research and Development", + "Non-Federal Agency", + "Northern Border Regional Commission", + "Nuclear Regulatory Commission", + "Nuclear Safety Oversight Committee", + "Occupational Safety and Health Review Commission", + "Office of Compliance", + "Office of Congressional Workplace Rights", + "Office of Government Ethics", + "Office of Navajo and Hopi Indian Relocation", + "Office of Personnel Management", + "Open World Leadership Center", + "Overseas Private Investment Corporation", + "Peace Corps", + "Pension Benefit Guaranty Corporation", + "Postal Regulatory Commission", + "Presidio Trust", + "Privacy and Civil Liberties Oversight Board", + "Public Buildings Reform Board", + "Public Defender Service for the District of Columbia", + "Railroad Retirement Board", + "Securities and Exchange Commission", + "Selective Service System", + "Small Business Administration", + "Smithsonian Institution", + "Social Security Administration", + "Social Security Advisory Board", + "Southeast Crescent Regional Commission", + "Southwest Border Regional Commission", + "State Justice Institute", + "Stennis Center for Public Service", + "Surface Transportation Board", + "Tennessee Valley Authority", + "The Executive Office of the President", + "The Intelligence Community", + "The Legislative Branch", + "The Supreme Court", + "The United States World War One Centennial Commission", + "U.S. Access Board", + "U.S. Agency for Global Media", + "U.S. Agency for International Development", + "U.S. Capitol Police", + "U.S. Chemical Safety Board", + "U.S. China Economic and Security Review Commission", + "U.S. Commission for the Preservation of Americas Heritage Abroad", + "U.S. Commission of Fine Arts", + "U.S. Commission on Civil Rights", + "U.S. Commission on International Religious Freedom", + "U.S. Courts", + "U.S. Department of Agriculture", + "U.S. Interagency Council on Homelessness", + "U.S. International Trade Commission", + "U.S. Nuclear Waste Technical Review Board", + "U.S. Office of Special Counsel", + "U.S. Postal Service", + "U.S. Semiquincentennial Commission", + "U.S. Trade and Development Agency", + "U.S.-China Economic and Security Review Commission", + "Udall Foundation", + "United States AbilityOne", + "United States Access Board", + "United States African Development Foundation", + "United States Agency for Global Media", + "United States Arctic Research Commission", + "United States Global Change Research Program", + "United States Holocaust Memorial Museum", + "United States Institute of Peace", + "United States Interagency Council on Homelessness", + "United States International Development Finance Corporation", + "United States International Trade Commission", + "United States Postal Service", + "United States Senate", + "United States Trade and Development Agency", + "Utah Reclamation Mitigation and Conservation Commission", + "Vietnam Education Foundation", + "Western Hemisphere Drug Policy Commission", + "Woodrow Wilson International Center for Scholars", + "World War I Centennial Commission", + ] + + FederalAgency = apps.get_model("registrar", "FederalAgency") + logger.info("Creating federal agency table.") + + try: + agencies = [FederalAgency(agency=agency) for agency in AGENCIES] + FederalAgency.objects.bulk_create(agencies) + except Exception as e: + logger.error(f"Error creating federal agencies: {e}") diff --git a/src/registrar/models/transition_domain.py b/src/registrar/models/transition_domain.py index 0c9c2ae66..eafbeda00 100644 --- a/src/registrar/models/transition_domain.py +++ b/src/registrar/models/transition_domain.py @@ -49,7 +49,7 @@ class TransitionDomain(TimeStampedModel): verbose_name="Processed", help_text="Indicates whether this TransitionDomain was already processed", ) - organization_type = models.CharField( + generic_org_type = models.CharField( max_length=255, null=True, blank=True, @@ -147,7 +147,7 @@ class TransitionDomain(TimeStampedModel): f"username: {self.username}, \n" f"status: {self.status}, \n" f"email sent: {self.email_sent}, \n" - f"organization type: {self.organization_type}, \n" + f"organization type: {self.generic_org_type}, \n" f"organization_name: {self.organization_name}, \n" f"federal_type: {self.federal_type}, \n" f"federal_agency: {self.federal_agency}, \n" diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index a84da798a..2aa2f642e 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -71,6 +71,11 @@ class UserGroup(Group): "model": "verifiedbystaff", "permissions": ["add_verifiedbystaff", "change_verifiedbystaff", "delete_verifiedbystaff"], }, + { + "app_label": "registrar", + "model": "federalagency", + "permissions": ["add_federalagency", "change_federalagency", "delete_federalagency"], + }, ] # Avoid error: You can't execute queries until the end diff --git a/src/registrar/templates/domain_authorizing_official.html b/src/registrar/templates/domain_authorizing_official.html index 2e2faa0d3..aa9808c2e 100644 --- a/src/registrar/templates/domain_authorizing_official.html +++ b/src/registrar/templates/domain_authorizing_official.html @@ -12,7 +12,7 @@
Your authorizing official is a person within your organization who can authorize domain requests. This person must be in a role of significant, executive responsibility within the organization. Read more about who can serve as an authorizing official.
- {% if organization_type == "federal" or organization_type == "tribal" %} + {% if generic_org_type == "federal" or generic_org_type == "tribal" %}The authorizing official for your organization can’t be updated here. To suggest an update, email help@get.gov. @@ -25,7 +25,7 @@
diff --git a/src/registrar/templates/domain_org_name_address.html b/src/registrar/templates/domain_org_name_address.html index 180fdd2a6..1e6176aa0 100644 --- a/src/registrar/templates/domain_org_name_address.html +++ b/src/registrar/templates/domain_org_name_address.html @@ -11,12 +11,12 @@The name of your organization will be publicly listed as the domain registrant.
- {% if domain.domain_info.organization_type == "federal" %} + {% if domain.domain_info.generic_org_type == "federal" %}The federal agency for your organization can’t be updated here. To suggest an update, email help@get.gov.
- {% elif domain.domain_info.organization_type == "tribal" %} + {% elif domain.domain_info.generic_org_type == "tribal" %}Your organization name can’t be updated here. To suggest an update, email help@get.gov. @@ -28,7 +28,7 @@