diff --git a/.github/workflows/deploy-manual.yaml b/.github/workflows/deploy-manual.yaml index ba85342b0..a85cc7565 100644 --- a/.github/workflows/deploy-manual.yaml +++ b/.github/workflows/deploy-manual.yaml @@ -14,6 +14,7 @@ on: options: - ab - backup + - el - cb - dk - es diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index fe0a19089..e9eb06627 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -30,6 +30,7 @@ jobs: || startsWith(github.head_ref, 'ag/') || startsWith(github.head_ref, 'ms/') || startsWith(github.head_ref, 'ad/') + || startsWith(github.head_ref, 'el/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index 70ff8ee95..1853b3c4f 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -16,6 +16,7 @@ on: - stable - staging - development + - el - ad - ms - ag diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index b6fa0fec5..111555b3c 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -16,6 +16,7 @@ on: options: - staging - development + - el - ad - ms - ag diff --git a/docs/developer/README.md b/docs/developer/README.md index 9ddb35352..0fa9d9a8c 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -173,7 +173,7 @@ You can change the logging verbosity, if needed. Do a web search for "django log ## Mock data -[load.py](../../src/registrar/management/commands/load.py) called from docker-compose (locally) and reset-db.yml (upper) loads the fixtures from [fixtures_user.py](../../src/registrar/fixtures_users.py) and [fixtures_domain_requests.py](../../src/registrar/fixtures_domain_requests.py), giving you some test data to play with while developing. +[load.py](../../src/registrar/management/commands/load.py) called from docker-compose (locally) and reset-db.yml (upper) loads the fixtures from [fixtures_user.py](../../src/registrar/fixtures/fixtures_users.py) and the rest of the data-loading fixtures in that fixtures folder, giving you some test data to play with while developing. See the [database-access README](./database-access.md) for information on how to pull data to update these fixtures. diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 5e1aa688a..a234d882b 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -754,7 +754,7 @@ Example: `cf ssh getgov-za` | 1 | **emailTo** | Specifies where the email will be emailed. Defaults to help@get.gov on production. | ## Populate federal agency initials and FCEB -This script adds to the "is_fceb" and "initials" fields on the FederalAgency model. This script expects a CSV of federal CIOs to pull from, which can be sourced from [here](https://docs.google.com/spreadsheets/d/14oXHFpKyUXS5_mDWARPusghGdHCrP67jCleOknaSx38/edit?gid=479328070#gid=479328070). +This script adds to the "is_fceb" and "acronym" fields on the FederalAgency model. This script expects a CSV of federal CIOs to pull from, which can be sourced from [here](https://docs.google.com/spreadsheets/d/14oXHFpKyUXS5_mDWARPusghGdHCrP67jCleOknaSx38/edit?gid=479328070#gid=479328070). ### Running on sandboxes diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md index b7fd50d52..224fe9a5f 100644 --- a/docs/operations/import_export.md +++ b/docs/operations/import_export.md @@ -9,17 +9,16 @@ Simple scripts are provided as detailed below. ### Export To export from the source environment, run the following command from src directory: -manage.py export_tables Connect to the source sandbox and run the command: -cf ssh {source-app} -/tmp/lifecycle/shell -./manage.py export_tables +`cf ssh {source-app}` +`/tmp/lifecycle/shell` +`./manage.py export_tables` example exporting from getgov-stable: -cf ssh getgov-stable -/tmp/lifecycle/shell -./manage.py export_tables +`cf ssh getgov-stable` +`/tmp/lifecycle/shell` +`./manage.py export_tables` This exports a file, exported_tables.zip, to the tmp directory @@ -42,14 +41,16 @@ After exporting the file from the target environment, scp the exported_tables.zi file from the target environment to local. Run the below commands from local. Get passcode by running: -cf ssh-code +`cf ssh-code` scp file from source app to local file: -scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {source-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip {local_file_path} +`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {source-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip {local_file_path}` when prompted, supply the passcode retrieved in the 'cf ssh-code' command example copying from stable to local cwd: -scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip . +`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip .` + +`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip .` ### Import @@ -63,14 +64,14 @@ that there are no database conflicts on import. In order to delete all rows from the appropriate tables, run the following command: -cf ssh {target-app} -/tmp/lifecycle/shell -./manage.py clean_tables +`cf ssh {target-app}` +`/tmp/lifecycle/shell` +`./manage.py clean_tables` example cleaning getgov-backup: -cf ssh getgov-backup -/tmp/lifecycle/backup -./manage.py clean_tables +`cf ssh getgov-backup` +`/tmp/lifecycle/shell` +`./manage.py clean_tables` For reference, this deletes all rows from the following tables: @@ -96,28 +97,30 @@ with --skipEppSave option set to False. If you set to False, it will attempt to records to the registry on load. If this is unset, or set to True, it will load the database and not attempt to update the registry on load. +Please note that there is currently a bug (missing batch importing, see #2862) so this may not work +smoothly right now currently. + To scp the exported_tables.zip file from local to the sandbox, run the following: Get passcode by running: -cf ssh-code +`cf ssh-code` scp file from local to target app: -scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {target-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 {local_file_path} ssh.fr.cloud.gov:app/tmp/exported_tables.zip +`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {target-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 {local_file_path} ssh.fr.cloud.gov:app/tmp/exported_tables.zip` when prompted, supply the passcode retrieved in the 'cf ssh-code' command example copy of local file in tmp to getgov-backup: -scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-backup --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 tmp/exported_tables.zip ssh.fr.cloud.gov:app/tmp/exported_tables.zip - +`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-backup --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 exported_tables.zip ssh.fr.cloud.gov:app/tmp/exported_tables.zip` Then connect to a shell in the target environment, and run the following import command: -cf ssh {target-app} -/tmp/lifecycle/shell -./manage.py import_tables +`cf ssh {target-app}` +`/tmp/lifecycle/shell` +`./manage.py import_tables` example cleaning getgov-backup: -cf ssh getgov-backup -/tmp/lifecycle/backup -./manage.py import_tables --no-skipEppSave +`cf ssh getgov-backup` +`/tmp/lifecycle/shell` +`./manage.py import_tables --no-skipEppSave` For reference, this imports tables in the following order: diff --git a/ops/manifests/manifest-el.yaml b/ops/manifests/manifest-el.yaml new file mode 100644 index 000000000..4c7d4d4e4 --- /dev/null +++ b/ops/manifests/manifest-el.yaml @@ -0,0 +1,32 @@ +--- +applications: +- name: getgov-el + 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-el.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-el.app.cloud.gov + services: + - getgov-credentials + - getgov-el-database diff --git a/src/api/tests/test_rdap.py b/src/api/tests/test_rdap.py new file mode 100644 index 000000000..789a99152 --- /dev/null +++ b/src/api/tests/test_rdap.py @@ -0,0 +1,66 @@ +"""Test the domain rdap lookup API.""" + +import json + +from django.contrib.auth import get_user_model +from django.test import RequestFactory +from django.test import TestCase + +from ..views import rdap + +API_BASE_PATH = "/api/v1/rdap/?domain=" + + +class RdapViewTest(TestCase): + """Test that the RDAP view function works as expected""" + + def setUp(self): + super().setUp() + self.user = get_user_model().objects.create(username="username") + self.factory = RequestFactory() + + def test_rdap_get_no_tld(self): + """RDAP API successfully fetches RDAP for domain without a TLD""" + request = self.factory.get(API_BASE_PATH + "whitehouse") + request.user = self.user + response = rdap(request, domain="whitehouse") + # contains the right text + self.assertContains(response, "rdap") + # can be parsed into JSON with appropriate keys + response_object = json.loads(response.content) + self.assertIn("rdapConformance", response_object) + + def test_rdap_invalid_domain(self): + """RDAP API accepts invalid domain queries and returns JSON response + with appropriate error codes""" + request = self.factory.get(API_BASE_PATH + "whitehouse.com") + request.user = self.user + response = rdap(request, domain="whitehouse.com") + + self.assertContains(response, "errorCode") + response_object = json.loads(response.content) + self.assertIn("errorCode", response_object) + + +class RdapAPITest(TestCase): + """Test that the API can be called as expected.""" + + def setUp(self): + super().setUp() + username = "test_user" + first_name = "First" + last_name = "Last" + email = "info@example.com" + title = "title" + phone = "8080102431" + self.user = get_user_model().objects.create( + username=username, title=title, first_name=first_name, last_name=last_name, email=email, phone=phone + ) + + def test_rdap_get(self): + """Can call RDAP API""" + self.client.force_login(self.user) + response = self.client.get(API_BASE_PATH + "whitehouse.gov") + self.assertContains(response, "rdap") + response_object = json.loads(response.content) + self.assertIn("rdapConformance", response_object) diff --git a/src/api/views.py b/src/api/views.py index 2199e15ac..a7b4bde75 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -2,7 +2,7 @@ from django.apps import apps from django.views.decorators.http import require_http_methods -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.utils.safestring import mark_safe from registrar.templatetags.url_helpers import public_site_url @@ -18,7 +18,7 @@ from cachetools.func import ttl_cache from registrar.utility.s3_bucket import S3ClientError, S3ClientHelper -DOMAIN_FILE_URL = "https://raw.githubusercontent.com/cisagov/dotgov-data/main/current-full.csv" +RDAP_URL = "https://rdap.cloudflareregistry.com/rdap/domain/{domain}" DOMAIN_API_MESSAGES = { @@ -41,30 +41,6 @@ DOMAIN_API_MESSAGES = { } -# this file doesn't change that often, nor is it that big, so cache the result -# in memory for ten minutes -@ttl_cache(ttl=600) -def _domains(): - """Return a list of the current .gov domains. - - Fetch a file from DOMAIN_FILE_URL, parse the CSV for the domain, - lowercase everything and return the list. - """ - DraftDomain = apps.get_model("registrar.DraftDomain") - # 5 second timeout - file_contents = requests.get(DOMAIN_FILE_URL, timeout=5).text - domains = set() - # skip the first line - for line in file_contents.splitlines()[1:]: - # get the domain before the first comma - domain = line.split(",", 1)[0] - # sanity-check the string we got from the file here - if DraftDomain.string_could_be_domain(domain): - # lowercase everything when we put it in domains - domains.add(domain.lower()) - return domains - - def check_domain_available(domain): """Return true if the given domain is available. @@ -99,6 +75,22 @@ def available(request, domain=""): return json_response +@require_http_methods(["GET"]) +@login_not_required +# Since we cache domain RDAP data, cache time may need to be re-evaluated this if we encounter any memory issues +@ttl_cache(ttl=600) +def rdap(request, domain=""): + """Returns JSON dictionary of a domain's RDAP data from Cloudflare API""" + domain = request.GET.get("domain", "") + + # If inputted domain doesn't have a TLD, append .gov to it + if "." not in domain: + domain = f"{domain}.gov" + + rdap_data = requests.get(RDAP_URL.format(domain=domain), timeout=5).json() + return JsonResponse(rdap_data) + + @require_http_methods(["GET"]) @login_not_required def get_current_full(request, file_name="current-full.csv"): diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 40c2bc050..ca51e8b72 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1,8 +1,6 @@ from datetime import date import logging import copy -import json -from django.template.loader import get_template from django import forms from django.db.models import Value, CharField, Q from django.db.models.functions import Concat, Coalesce @@ -10,8 +8,7 @@ from django.http import HttpResponseRedirect from django.conf import settings from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField -from registrar.models.domain_information import DomainInformation -from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active from django.contrib import admin, messages @@ -23,6 +20,11 @@ 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.utility.admin_helpers import ( + get_all_action_needed_reason_emails, + get_action_needed_reason_default_email, + get_field_links_as_list, +) from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.utility.constants import BranchChoices from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes @@ -757,9 +759,10 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): }, ), ("Important dates", {"fields": ("last_login", "date_joined")}), + ("Associated portfolios", {"fields": ("portfolios",)}), ) - readonly_fields = ("verification_type",) + readonly_fields = ("verification_type", "portfolios") analyst_fieldsets = ( ( @@ -782,6 +785,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): }, ), ("Important dates", {"fields": ("last_login", "date_joined")}), + ("Associated portfolios", {"fields": ("portfolios",)}), ) # TODO: delete after we merge organization feature @@ -861,6 +865,14 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): ordering = ["first_name", "last_name", "email"] search_help_text = "Search by first name, last name, or email." + def portfolios(self, obj: models.User): + """Returns a list of links for each related suborg""" + portfolio_ids = obj.get_portfolios().values_list("portfolio", flat=True) + queryset = models.Portfolio.objects.filter(id__in=portfolio_ids) + return get_field_links_as_list(queryset, "portfolio", msg_for_none="No portfolios.") + + portfolios.short_description = "Portfolios" # type: ignore + def get_search_results(self, request, queryset, search_term): """ Override for get_search_results. This affects any upstream model using autocomplete_fields, @@ -1257,9 +1269,18 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin): list_display = [ "user", "portfolio", + "get_roles", ] autocomplete_fields = ["user", "portfolio"] + search_fields = ["user__first_name", "user__last_name", "user__email", "portfolio__organization_name"] + search_help_text = "Search by first name, last name, email, or portfolio." + + def get_roles(self, obj): + readable_roles = obj.get_readable_roles() + return ", ".join(readable_roles) + + get_roles.short_description = "Roles" # type: ignore class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): @@ -1543,33 +1564,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): change_form_template = "django/admin/domain_information_change_form.html" - superuser_only_fields = [ - "portfolio", - "sub_organization", - ] - - # DEVELOPER's NOTE: - # Normally, to exclude a field from an Admin form, we could simply utilize - # Django's "exclude" feature. However, it causes a "missing key" error if we - # go that route for this particular form. The error gets thrown by our - # custom fieldset.html code and is due to the fact that "exclude" removes - # fields from base_fields but not fieldsets. Rather than reworking our - # custom frontend, it seems more straightforward (and easier to read) to simply - # modify the fieldsets list so that it excludes any fields we want to remove - # based on permissions (eg. superuser_only_fields) or other conditions. - def get_fieldsets(self, request, obj=None): - fieldsets = self.fieldsets - - # Create a modified version of fieldsets to exclude certain fields - if not request.user.has_perm("registrar.full_access_permission"): - modified_fieldsets = [] - for name, data in fieldsets: - fields = data.get("fields", []) - fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields) - modified_fieldsets.append((name, {**data, "fields": fields})) - return modified_fieldsets - return fieldsets - 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: @@ -1865,33 +1859,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ] filter_horizontal = ("current_websites", "alternative_domains", "other_contacts") - superuser_only_fields = [ - "portfolio", - "sub_organization", - ] - - # DEVELOPER's NOTE: - # Normally, to exclude a field from an Admin form, we could simply utilize - # Django's "exclude" feature. However, it causes a "missing key" error if we - # go that route for this particular form. The error gets thrown by our - # custom fieldset.html code and is due to the fact that "exclude" removes - # fields from base_fields but not fieldsets. Rather than reworking our - # custom frontend, it seems more straightforward (and easier to read) to simply - # modify the fieldsets list so that it excludes any fields we want to remove - # based on permissions (eg. superuser_only_fields) or other conditions. - def get_fieldsets(self, request, obj=None): - fieldsets = super().get_fieldsets(request, obj) - - # Create a modified version of fieldsets to exclude certain fields - if not request.user.has_perm("registrar.full_access_permission"): - modified_fieldsets = [] - for name, data in fieldsets: - fields = data.get("fields", []) - fields = tuple(field for field in fields if field not in self.superuser_only_fields) - modified_fieldsets.append((name, {**data, "fields": fields})) - return modified_fieldsets - return fieldsets - # Table ordering # NOTE: This impacts the select2 dropdowns (combobox) # Currentl, there's only one for requests on DomainInfo @@ -1948,9 +1915,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Set the action_needed_reason_email to the default if nothing exists. # Since this check occurs after save, if the user enters a value then we won't update. - default_email = self._get_action_needed_reason_default_email(obj, obj.action_needed_reason) + default_email = get_action_needed_reason_default_email(request, obj, obj.action_needed_reason) if obj.action_needed_reason_email: - emails = self.get_all_action_needed_reason_emails(obj) + emails = get_all_action_needed_reason_emails(request, obj) is_custom_email = obj.action_needed_reason_email not in emails.values() if not is_custom_email: obj.action_needed_reason_email = default_email @@ -2180,8 +2147,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Initialize extra_context and add filtered entries extra_context = extra_context or {} extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries - emails = self.get_all_action_needed_reason_emails(obj) - extra_context["action_needed_reason_emails"] = json.dumps(emails) # Denote if an action needed email was sent or not email_sent = request.session.get("action_needed_email_sent", False) @@ -2192,39 +2157,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Call the superclass method with updated extra_context return super().change_view(request, object_id, form_url, extra_context) - def get_all_action_needed_reason_emails(self, domain_request): - """Returns a json dictionary of every action needed reason and its associated email - for this particular domain request.""" - - emails = {} - for action_needed_reason in domain_request.ActionNeededReasons: - # Map the action_needed_reason to its default email - emails[action_needed_reason.value] = self._get_action_needed_reason_default_email( - domain_request, action_needed_reason.value - ) - - return emails - - def _get_action_needed_reason_default_email(self, domain_request, action_needed_reason): - """Returns the default email associated with the given action needed reason""" - if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER: - return None - - recipient = domain_request.creator - - # Return the context of the rendered views - context = {"domain_request": domain_request, "recipient": recipient} - - # Get the email body - template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt" - - email_body_text = get_template(template_path).render(context=context) - email_body_text_cleaned = None - if email_body_text: - email_body_text_cleaned = email_body_text.strip().lstrip("\n") - - return email_body_text_cleaned - def process_log_entry(self, log_entry): """Process a log entry and return filtered entry dictionary if applicable.""" changes = log_entry.changes @@ -2335,10 +2267,58 @@ class DomainInformationInline(admin.StackedInline): template = "django/admin/includes/domain_info_inline_stacked.html" model = models.DomainInformation - fieldsets = DomainInformationAdmin.fieldsets - readonly_fields = DomainInformationAdmin.readonly_fields - analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields - autocomplete_fields = DomainInformationAdmin.autocomplete_fields + fieldsets = copy.deepcopy(list(DomainInformationAdmin.fieldsets)) + analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_readonly_fields) + autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields) + + def get_domain_managers(self, obj): + user_domain_roles = UserDomainRole.objects.filter(domain=obj.domain) + user_ids = user_domain_roles.values_list("user_id", flat=True) + domain_managers = User.objects.filter(id__in=user_ids) + return domain_managers + + def get_domain_invitations(self, obj): + domain_invitations = DomainInvitation.objects.filter( + domain=obj.domain, status=DomainInvitation.DomainInvitationStatus.INVITED + ) + return domain_invitations + + def domain_managers(self, obj): + """Get domain managers for the domain, unpack and return an HTML block.""" + domain_managers = self.get_domain_managers(obj) + if not domain_managers: + return "No domain managers found." + + domain_manager_details = "" + for domain_manager in domain_managers: + full_name = domain_manager.get_formatted_name() + change_url = reverse("admin:registrar_user_change", args=[domain_manager.pk]) + domain_manager_details += "" + domain_manager_details += f'" + domain_manager_details += f"" + domain_manager_details += "" + domain_manager_details += "
UIDNameEmail
{escape(domain_manager.username)}' + domain_manager_details += f"{escape(full_name)}{escape(domain_manager.email)}
" + return format_html(domain_manager_details) + + domain_managers.short_description = "Domain managers" # type: ignore + + def invited_domain_managers(self, obj): + """Get emails which have been invited to the domain, unpack and return an HTML block.""" + domain_invitations = self.get_domain_invitations(obj) + if not domain_invitations: + return "No invited domain managers found." + + domain_invitation_details = "" + "" + for domain_invitation in domain_invitations: + domain_invitation_details += "" + domain_invitation_details += f"" + domain_invitation_details += f"" + domain_invitation_details += "" + domain_invitation_details += "
EmailStatus
{escape(domain_invitation.email)}{escape(domain_invitation.status.capitalize())}
" + return format_html(domain_invitation_details) + + invited_domain_managers.short_description = "Invited domain managers" # type: ignore def has_change_permission(self, request, obj=None): """Custom has_change_permission override so that we can specify that @@ -2378,7 +2358,9 @@ class DomainInformationInline(admin.StackedInline): return super().formfield_for_foreignkey(db_field, request, **kwargs) def get_readonly_fields(self, request, obj=None): - return DomainInformationAdmin.get_readonly_fields(self, request, obj=None) + readonly_fields = copy.deepcopy(DomainInformationAdmin.get_readonly_fields(self, request, obj=None)) + readonly_fields.extend(["domain_managers", "invited_domain_managers"]) # type: ignore + return readonly_fields # Re-route the get_fieldsets method to utilize DomainInformationAdmin.get_fieldsets # since that has all the logic for excluding certain fields according to user permissions. @@ -2387,13 +2369,34 @@ class DomainInformationInline(admin.StackedInline): def get_fieldsets(self, request, obj=None): # Grab fieldsets from DomainInformationAdmin so that it handles all logic # for permission-based field visibility. - modified_fieldsets = DomainInformationAdmin.get_fieldsets(self, request, obj=None) + modified_fieldsets = copy.deepcopy(DomainInformationAdmin.get_fieldsets(self, request, obj=None)) - # remove .gov domain from fieldset + # Modify fieldset sections in place + for index, (title, options) in enumerate(modified_fieldsets): + if title is None: + options["fields"] = [ + field for field in options["fields"] if field not in ["creator", "domain_request", "notes"] + ] + elif title == "Contacts": + options["fields"] = [ + field + for field in options["fields"] + if field not in ["other_contacts", "no_other_contacts_rationale"] + ] + options["fields"].extend(["domain_managers", "invited_domain_managers"]) # type: ignore + elif title == "Background info": + # move domain request and notes to background + options["fields"].extend(["domain_request", "notes"]) # type: ignore + + # Remove or remove fieldset sections for index, (title, f) in enumerate(modified_fieldsets): if title == ".gov domain": - del modified_fieldsets[index] - break + # remove .gov domain from fieldset + modified_fieldsets.pop(index) + elif title == "Background info": + # move Background info to the bottom of the list + fieldsets_to_move = modified_fieldsets.pop(index) + modified_fieldsets.append(fieldsets_to_move) return modified_fieldsets @@ -2451,13 +2454,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): fieldsets = ( ( None, - {"fields": ["name", "state", "expiration_date", "first_ready", "deleted"]}, + {"fields": ["state", "expiration_date", "first_ready", "deleted", "dnssecdata", "nameservers"]}, ), ) - # this ordering effects the ordering of results in autocomplete_fields for domain - ordering = ["name"] - def generic_org_type(self, obj): return obj.domain_info.get_generic_org_type_display() @@ -2478,6 +2478,28 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): organization_name.admin_order_field = "domain_info__organization_name" # type: ignore + def dnssecdata(self, obj): + return "Yes" if obj.dnssecdata else "No" + + dnssecdata.short_description = "DNSSEC enabled" # type: ignore + + # Custom method to display formatted nameservers + def nameservers(self, obj): + if not obj.nameservers: + return "No nameservers" + + formatted_nameservers = [] + for server, ip_list in obj.nameservers: + server_display = str(server) + if ip_list: + server_display += f" [{', '.join(ip_list)}]" + formatted_nameservers.append(server_display) + + # Join the formatted strings with line breaks + return "\n".join(formatted_nameservers) + + nameservers.short_description = "Name servers" # type: ignore + def custom_election_board(self, obj): domain_info = getattr(obj, "domain_info", None) if domain_info: @@ -2504,7 +2526,15 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): search_fields = ["name"] search_help_text = "Search by domain name." change_form_template = "django/admin/domain_change_form.html" - readonly_fields = ("state", "expiration_date", "first_ready", "deleted", "federal_agency") + readonly_fields = ( + "state", + "expiration_date", + "first_ready", + "deleted", + "federal_agency", + "dnssecdata", + "nameservers", + ) # Table ordering ordering = ["name"] @@ -2943,39 +2973,59 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): class PortfolioAdmin(ListHeaderAdmin): + + class Meta: + """Contains meta information about this class""" + + model = models.Portfolio + fields = "__all__" + + _meta = Meta() + change_form_template = "django/admin/portfolio_change_form.html" fieldsets = [ - # created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}" - (None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}), - ("Portfolio members", {"fields": ["display_admins", "display_members"]}), - ("Portfolio domains", {"fields": ["domains", "domain_requests"]}), + # created_on is the created_at field + (None, {"fields": ["creator", "created_on", "notes"]}), ("Type of organization", {"fields": ["organization_type", "federal_type"]}), ( "Organization name and mailing address", { "fields": [ + "organization_name", "federal_agency", + ] + }, + ), + ( + "Show details", + { + "classes": ["collapse--dgfieldset"], + "description": "Extends organization name and mailing address", + "fields": [ "state_territory", "address_line1", "address_line2", "city", "zipcode", "urbanization", - ] + ], }, ), + ("Portfolio members", {"fields": ["display_admins", "display_members"]}), + ("Domains and requests", {"fields": ["domains", "domain_requests"]}), ("Suborganizations", {"fields": ["suborganizations"]}), ("Senior official", {"fields": ["senior_official"]}), ] # This is the fieldset display when adding a new model add_fieldsets = [ - (None, {"fields": ["organization_name", "creator", "notes"]}), + (None, {"fields": ["creator", "notes"]}), ("Type of organization", {"fields": ["organization_type"]}), ( "Organization name and mailing address", { "fields": [ + "organization_name", "federal_agency", "state_territory", "address_line1", @@ -2989,7 +3039,7 @@ class PortfolioAdmin(ListHeaderAdmin): ("Senior official", {"fields": ["senior_official"]}), ] - list_display = ("organization_name", "federal_agency", "creator") + list_display = ("organization_name", "organization_type", "federal_type", "creator") search_fields = ["organization_name"] search_help_text = "Search by organization name." readonly_fields = [ @@ -3002,23 +3052,35 @@ class PortfolioAdmin(ListHeaderAdmin): "domains", "domain_requests", "suborganizations", - "portfolio_type", "display_admins", "display_members", "creator", + # As of now this means that only federal agency can update this, but this will change. + "senior_official", + ] + + analyst_readonly_fields = [ + "organization_name", ] def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio - admin_permissions = UserPortfolioPermission.objects.filter( - portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - ) + admin_permissions = self.get_user_portfolio_permission_admins(obj) # Get the user objects associated with these permissions admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions) return admin_users + def get_user_portfolio_permission_admins(self, obj): + """Returns each admin on UserPortfolioPermission for a given portfolio.""" + if obj: + return obj.portfolio_users.filter( + portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + else: + return [] + def get_non_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude( @@ -3030,82 +3092,12 @@ class PortfolioAdmin(ListHeaderAdmin): return non_admin_users - def display_admins(self, obj): - """Get joined users who are Admin, unpack and return an HTML block. - - 'DJA readonly can't handle querysets, so we need to unpack and return html here. - Alternatively, we could return querysets in context but that would limit where this - data would display in a custom change form without extensive template customization. - - Will be used in the field_readonly block""" - admins = self.get_admin_users(obj) - if not admins: - return format_html("

No admins found.

") - - admin_details = "" - for portfolio_admin in admins: - change_url = reverse("admin:registrar_user_change", args=[portfolio_admin.pk]) - admin_details += "
" - admin_details += f'{escape(portfolio_admin)}
' - admin_details += f"{escape(portfolio_admin.title)}
" - admin_details += f"{escape(portfolio_admin.email)}" - admin_details += "
" - admin_details += f"{escape(portfolio_admin.phone)}" - admin_details += "
" - return format_html(admin_details) - - display_admins.short_description = "Administrators" # type: ignore - - def display_members(self, obj): - """Get joined users who have roles/perms that are not Admin, unpack and return an HTML block. - - DJA readonly can't handle querysets, so we need to unpack and return html here. - Alternatively, we could return querysets in context but that would limit where this - data would display in a custom change form without extensive template customization. - - Will be used in the after_help_text block.""" - members = self.get_non_admin_users(obj) - if not members: - return "" - - member_details = ( - "" - + "" - ) - for member in members: - full_name = member.get_formatted_name() - member_details += "" - member_details += f"" - member_details += f"" - member_details += f"" - member_details += f"" - member_details += "" - member_details += "
NameTitleEmailPhoneRoles
{escape(full_name)}{escape(member.title)}{escape(member.email)}{escape(member.phone)}" - for role in member.portfolio_role_summary(obj): - member_details += f"{escape(role)} " - member_details += "
" - return format_html(member_details) - - display_members.short_description = "Members" # type: ignore - - def display_members_summary(self, obj): - """Will be passed as context and used in the field_readonly block.""" - members = self.get_non_admin_users(obj) - if not members: - return {} - - return self.get_field_links_as_list(members, "user", separator=", ") + def get_user_portfolio_permission_non_admins(self, obj): + """Returns each admin on UserPortfolioPermission for a given portfolio.""" + if obj: + return obj.portfolio_users.exclude(roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) + else: + return [] def federal_type(self, obj: models.Portfolio): """Returns the federal_type field""" @@ -3120,16 +3112,10 @@ class PortfolioAdmin(ListHeaderAdmin): created_on.short_description = "Created on" # type: ignore - def portfolio_type(self, obj: models.Portfolio): - """Returns the portfolio type, or "-" if the result is empty""" - return obj.portfolio_type if obj.portfolio_type else "-" - - portfolio_type.short_description = "Portfolio type" # type: ignore - def suborganizations(self, obj: models.Portfolio): """Returns a list of links for each related suborg""" queryset = obj.get_suborganizations() - return self.get_field_links_as_list(queryset, "suborganization") + return get_field_links_as_list(queryset, "suborganization") suborganizations.short_description = "Suborganizations" # type: ignore @@ -3158,6 +3144,28 @@ class PortfolioAdmin(ListHeaderAdmin): domain_requests.short_description = "Domain requests" # type: ignore + def display_admins(self, obj): + """Returns the number of administrators for this portfolio""" + admin_count = len(self.get_user_portfolio_permission_admins(obj)) + if admin_count > 0: + url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" + # Create a clickable link with the count + return format_html(f'{admin_count} administrators') + return "No administrators found." + + display_admins.short_description = "Administrators" # type: ignore + + def display_members(self, obj): + """Returns the number of members for this portfolio""" + member_count = len(self.get_user_portfolio_permission_non_admins(obj)) + if member_count > 0: + url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" + # Create a clickable link with the count + return format_html(f'{member_count} members') + return "No additional members found." + + display_members.short_description = "Members" # type: ignore + # Creates select2 fields (with search bars) autocomplete_fields = [ "creator", @@ -3165,59 +3173,6 @@ class PortfolioAdmin(ListHeaderAdmin): "senior_official", ] - def get_field_links_as_list( - self, queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None - ): - """ - Generate HTML links for items in a queryset, using a specified attribute for link text. - - Args: - queryset: The queryset of items to generate links for. - model_name: The model name used to construct the admin change URL. - attribute_name: The attribute or method name to use for link text. If None, the item itself is used. - link_info_attribute: Appends f"({value_of_attribute})" to the end of the link. - separator: The separator to use between links in the resulting HTML. - If none, an unordered list is returned. - - Returns: - A formatted HTML string with links to the admin change pages for each item. - """ - links = [] - for item in queryset: - - # This allows you to pass in attribute_name="get_full_name" for instance. - if attribute_name: - item_display_value = self.value_of_attribute(item, attribute_name) - else: - item_display_value = item - - if item_display_value: - change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk]) - - link = f'{escape(item_display_value)}' - if link_info_attribute: - link += f" ({self.value_of_attribute(item, link_info_attribute)})" - - if separator: - links.append(link) - else: - links.append(f"
  • {link}
  • ") - - # If no separator is specified, just return an unordered list. - if separator: - return format_html(separator.join(links)) if links else "-" - else: - links = "".join(links) - return format_html(f'') if links else "-" - - def value_of_attribute(self, obj, attribute_name: str): - """Returns the value of getattr if the attribute isn't callable. - If it is, execute the underlying function and return that result instead.""" - value = getattr(obj, attribute_name) - if callable(value): - value = value() - return value - def get_fieldsets(self, request, obj=None): """Override of the default get_fieldsets definition to add an add_fieldsets view""" # This is the add view if no obj exists @@ -3250,10 +3205,15 @@ class PortfolioAdmin(ListHeaderAdmin): def change_view(self, request, object_id, form_url="", extra_context=None): """Add related suborganizations and domain groups. Add the summary for the portfolio members field (list of members that link to change_forms).""" - obj = self.get_object(request, object_id) + obj: Portfolio = self.get_object(request, object_id) extra_context = extra_context or {} extra_context["skip_additional_contact_info"] = True - extra_context["display_members_summary"] = self.display_members_summary(obj) + + if obj: + extra_context["members"] = self.get_user_portfolio_permission_non_admins(obj) + extra_context["admins"] = self.get_user_portfolio_permission_admins(obj) + extra_context["domains"] = obj.get_domains(order_by=["domain__name"]) + extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"]) return super().change_view(request, object_id, form_url, extra_context) def save_model(self, request, obj, form, change): @@ -3270,6 +3230,14 @@ class PortfolioAdmin(ListHeaderAdmin): is_federal = obj.organization_type == DomainRequest.OrganizationChoices.FEDERAL if is_federal and obj.organization_name is None: obj.organization_name = obj.federal_agency.agency + + # Remove this line when senior_official is no longer readonly in /admin. + if obj.federal_agency: + if obj.federal_agency.so_federal_agency.exists(): + obj.senior_official = obj.federal_agency.so_federal_agency.first() + else: + obj.senior_official = None + super().save_model(request, obj, form, change) @@ -3284,7 +3252,7 @@ class FederalAgencyResource(resources.ModelResource): class FederalAgencyAdmin(ListHeaderAdmin, ImportExportModelAdmin): list_display = ["agency"] search_fields = ["agency"] - search_help_text = "Search by agency name." + search_help_text = "Search by federal agency." ordering = ["agency"] resource_classes = [FederalAgencyResource] @@ -3341,6 +3309,7 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): "portfolio", ] search_fields = ["name"] + search_help_text = "Search by suborganization." change_form_template = "django/admin/suborg_change_form.html" diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 7ff02ba1f..73f3dded1 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -504,167 +504,111 @@ function initializeWidgetOnList(list, parentId) { /** An IIFE that hooks to the show/hide button underneath action needed reason. * This shows the auto generated email on action needed reason. */ -(function () { - // Since this is an iife, these vars will be removed from memory afterwards - var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason"); - - // Placeholder text (for certain "action needed" reasons that do not involve e=mails) - var placeholderText = document.querySelector("#action-needed-reason-email-placeholder-text") +document.addEventListener('DOMContentLoaded', function() { + const dropdown = document.getElementById("id_action_needed_reason"); + const textarea = document.getElementById("id_action_needed_reason_email") + const domainRequestId = dropdown ? document.getElementById("domain_request_id").value : null + const textareaPlaceholder = document.querySelector(".field-action_needed_reason_email__placeholder"); + const directEditButton = document.querySelector('.field-action_needed_reason_email__edit'); + const modalTrigger = document.querySelector('.field-action_needed_reason_email__modal-trigger'); + const modalConfirm = document.getElementById('confirm-edit-email'); + const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]'); + let lastSentEmailContent = document.getElementById("last-sent-email-content"); + const initialDropdownValue = dropdown ? dropdown.value : null; + const initialEmailValue = textarea.value; - // E-mail divs and textarea components - var actionNeededEmail = document.querySelector("#id_action_needed_reason_email") - var actionNeededEmailReadonly = document.querySelector("#action-needed-reason-email-readonly") - var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea") - - // Edit e-mail modal (and its confirmation button) - var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button") - - // Headers and footers (which change depending on if the e-mail was sent or not) - var actionNeededEmailHeader = document.querySelector("#action-needed-email-header") - var actionNeededEmailHeaderOnSave = document.querySelector("#action-needed-email-header-email-sent") - var actionNeededEmailFooter = document.querySelector("#action-needed-email-footer") - - let emailWasSent = document.getElementById("action-needed-email-sent"); - let lastSentEmailText = document.getElementById("action-needed-email-last-sent-text"); - - // Get the list of e-mails associated with each action-needed dropdown value - let emailData = document.getElementById('action-needed-emails-data'); - if (!emailData) { - return; - } - let actionNeededEmailData = emailData.textContent; - if(!actionNeededEmailData) { - return; - } - let actionNeededEmailsJson = JSON.parse(actionNeededEmailData); - - const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null - const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`; - const oldDropdownValue = actionNeededReasonDropdown ? actionNeededReasonDropdown.value : null; - const oldEmailValue = actionNeededEmailData ? actionNeededEmailData.value : null; - - if(actionNeededReasonDropdown && actionNeededEmail && domainRequestId) { - // Add a change listener to dom load - document.addEventListener('DOMContentLoaded', function() { - let reason = actionNeededReasonDropdown.value; - - // Handle the session boolean (to enable/disable editing) - if (emailWasSent && emailWasSent.value === "True") { - // An email was sent out - store that information in a session variable - addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true); - } - - // Show an editable email field or a readonly one - updateActionNeededEmailDisplay(reason) - }); - - // editEmailButton.addEventListener("click", function() { - // if (!checkEmailAlreadySent()) { - // showEmail(canEdit=true) - // } - // }); - - confirmEditEmailButton.addEventListener("click", function() { - // Show editable view - showEmail(canEdit=true) - }); - - - // Add a change listener to the action needed reason dropdown - actionNeededReasonDropdown.addEventListener("change", function() { - let reason = actionNeededReasonDropdown.value; - let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null; - - if (reason && emailBody) { - // Reset the session object on change since change refreshes the email content. - if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) { - // Replace the email content - actionNeededEmail.value = emailBody; - actionNeededEmailReadonlyTextarea.value = emailBody; - hideEmailAlreadySentView(); - } - } - - // Show either a preview of the email or some text describing no email will be sent - updateActionNeededEmailDisplay(reason) - }); + // We will use the const to control the modal + let isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); + // We will use the function to control the label and help + function isEmailAlreadySent() { + return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); } - function checkEmailAlreadySent() - { - lastEmailSent = lastSentEmailText.value.replace(/\s+/g, '') - currentEmailInTextArea = actionNeededEmail.value.replace(/\s+/g, '') - return lastEmailSent === currentEmailInTextArea - } + if (!dropdown || !textarea || !domainRequestId || !formLabel || !modalConfirm) return; + const apiUrl = document.getElementById("get-action-needed-email-for-user-json").value; - // Shows a readonly preview of the email with updated messaging to indicate this email was sent - function showEmailAlreadySentView() - { - hideElement(actionNeededEmailHeader) - showElement(actionNeededEmailHeaderOnSave) - actionNeededEmailFooter.innerHTML = "This email has been sent to the creator of this request"; - } - - // Shows a readonly preview of the email with updated messaging to indicate this email was sent - function hideEmailAlreadySentView() - { - showElement(actionNeededEmailHeader) - hideElement(actionNeededEmailHeaderOnSave) - actionNeededEmailFooter.innerHTML = "This email will be sent to the creator of this request after saving"; - } - - // Shows either a preview of the email or some text describing no email will be sent. - // If the email doesn't exist or if we're of reason "other", display that no email was sent. - function updateActionNeededEmailDisplay(reason) { - hideElement(actionNeededEmail.parentElement) - - if (reason) { - if (reason === "other") { - // Hide email preview and show this text instead - showPlaceholderText("No email will be sent"); - } - else { - // Always show readonly view of email to start - showEmail(canEdit=false) - if(checkEmailAlreadySent()) - { - showEmailAlreadySentView(); - } - } + function updateUserInterface(reason) { + if (!reason) { + // No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text + formLabel.innerHTML = "Email:"; + textareaPlaceholder.innerHTML = "Select an action needed reason to see email"; + showElement(textareaPlaceholder); + hideElement(directEditButton); + hideElement(modalTrigger); + hideElement(textarea); + } else if (reason === 'other') { + // 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text + formLabel.innerHTML = "Email:"; + textareaPlaceholder.innerHTML = "No email will be sent"; + showElement(textareaPlaceholder); + hideElement(directEditButton); + hideElement(modalTrigger); + hideElement(textarea); } else { - // Hide email preview and show this text instead - showPlaceholderText("Select an action needed reason to see email"); + // A triggering selection is selected, all hands on board: + textarea.setAttribute('readonly', true); + showElement(textarea); + hideElement(textareaPlaceholder); + + if (isEmailAlreadySentConst) { + hideElement(directEditButton); + showElement(modalTrigger); + } else { + showElement(directEditButton); + hideElement(modalTrigger); + } + if (isEmailAlreadySent()) { + formLabel.innerHTML = "Email sent to creator:"; + } else { + formLabel.innerHTML = "Email:"; + } } } - // Shows either a readonly view (canEdit=false) or editable view (canEdit=true) of the action needed email - function showEmail(canEdit) - { - if(!canEdit) - { - showElement(actionNeededEmailReadonly) - hideElement(actionNeededEmail.parentElement) - } - else - { - hideElement(actionNeededEmailReadonly) - showElement(actionNeededEmail.parentElement) - } - showElement(actionNeededEmailFooter) // this is the same for both views, so it was separated out - hideElement(placeholderText) - } + // Initialize UI + updateUserInterface(dropdown.value); - // Hides preview of action needed email and instead displays the given text (innerHTML) - function showPlaceholderText(innerHTML) - { - hideElement(actionNeededEmail.parentElement) - hideElement(actionNeededEmailReadonly) - hideElement(actionNeededEmailFooter) + dropdown.addEventListener("change", function() { + const reason = dropdown.value; + // Update the UI + updateUserInterface(reason); + if (reason && reason !== "other") { + // If it's not the initial value + if (initialDropdownValue !== dropdown.value || initialEmailValue !== textarea.value) { + // Replace the email content + fetch(`${apiUrl}?reason=${reason}&domain_request_id=${domainRequestId}`) + .then(response => { + return response.json().then(data => data); + }) + .then(data => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + }else { + textarea.value = data.action_needed_email; + } + updateUserInterface(reason); + }) + .catch(error => { + console.error("Error action needed email: ", error) + }); + } + } - placeholderText.innerHTML = innerHTML; - showElement(placeholderText) - } -})(); + }); + + modalConfirm.addEventListener("click", () => { + textarea.removeAttribute('readonly'); + textarea.focus(); + hideElement(directEditButton); + hideElement(modalTrigger); + }); + directEditButton.addEventListener("click", () => { + textarea.removeAttribute('readonly'); + textarea.focus(); + hideElement(directEditButton); + hideElement(modalTrigger); + }); +}); /** An IIFE for copy summary button (appears in DomainRegistry models) @@ -858,10 +802,15 @@ function initializeWidgetOnList(list, parentId) { // $ symbolically denotes that this is using jQuery let $federalAgency = django.jQuery("#id_federal_agency"); let organizationType = document.getElementById("id_organization_type"); - if ($federalAgency && organizationType) { + let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly"); + + let organizationNameContainer = document.querySelector(".field-organization_name"); + let federalType = document.querySelector(".field-federal_type"); + + if ($federalAgency && (organizationType || readonlyOrganizationType)) { // Attach the change event listener $federalAgency.on("change", function() { - handleFederalAgencyChange($federalAgency, organizationType); + handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType); }); } @@ -877,9 +826,33 @@ function initializeWidgetOnList(list, parentId) { handleStateTerritoryChange(stateTerritory, urbanizationField); }); } + + // Handle hiding the organization name field when the organization_type is federal. + // Run this first one page load, then secondly on a change event. + handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); + organizationType.addEventListener("change", function() { + handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); + }); }); - function handleFederalAgencyChange(federalAgency, organizationType) { + function handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType) { + if (organizationType && organizationNameContainer) { + let selectedValue = organizationType.value; + if (selectedValue === "federal") { + hideElement(organizationNameContainer); + if (federalType) { + showElement(federalType); + } + } else { + showElement(organizationNameContainer); + if (federalType) { + hideElement(federalType); + } + } + } + } + + function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) { // Don't do anything on page load if (isInitialPageLoad) { isInitialPageLoad = false; @@ -894,27 +867,31 @@ function initializeWidgetOnList(list, parentId) { return; } + let organizationTypeValue = organizationType ? organizationType.value : readonlyOrganizationType.innerText.toLowerCase(); if (selectedText !== "Non-Federal Agency") { - if (organizationType.value !== "federal") { - organizationType.value = "federal"; + if (organizationTypeValue !== "federal") { + if (organizationType){ + organizationType.value = "federal"; + }else { + readonlyOrganizationType.innerText = "Federal" + } } }else { - if (organizationType.value === "federal") { - organizationType.value = ""; + if (organizationTypeValue === "federal") { + if (organizationType){ + organizationType.value = ""; + }else { + readonlyOrganizationType.innerText = "-" + } } } - // Get the associated senior official with this federal agency - let $seniorOfficial = django.jQuery("#id_senior_official"); - if (!$seniorOfficial) { - console.log("Could not find the senior official field"); - return; - } + handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); // Determine if any changes are necessary to the display of portfolio type or federal type // based on changes to the Federal Agency let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; - fetch(`${federalPortfolioApi}?organization_type=${organizationType.value}&agency_name=${selectedText}`) + fetch(`${federalPortfolioApi}?&agency_name=${selectedText}`) .then(response => { const statusCode = response.status; return response.json().then(data => ({ statusCode, data })); @@ -925,7 +902,6 @@ function initializeWidgetOnList(list, parentId) { return; } updateReadOnly(data.federal_type, '.field-federal_type'); - updateReadOnly(data.portfolio_type, '.field-portfolio_type'); }) .catch(error => console.error("Error fetching federal and portfolio types: ", error)); @@ -933,6 +909,9 @@ function initializeWidgetOnList(list, parentId) { // If we can update the contact information, it'll be shown again. hideElement(contactList.parentElement); + let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; + let $seniorOfficial = django.jQuery("#id_senior_official"); + let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly"); let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; fetch(`${seniorOfficialApi}?agency_name=${selectedText}`) .then(response => { @@ -943,7 +922,12 @@ function initializeWidgetOnList(list, parentId) { if (data.error) { // Clear the field if the SO doesn't exist. if (statusCode === 404) { - $seniorOfficial.val("").trigger("change"); + if ($seniorOfficial && $seniorOfficial.length > 0) { + $seniorOfficial.val("").trigger("change"); + }else { + // Show the "create one now" text if this field is none in readonly mode. + readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`; + } console.warn("Record not found: " + data.error); }else { console.error("Error in AJAX call: " + data.error); @@ -954,30 +938,43 @@ function initializeWidgetOnList(list, parentId) { // Update the "contact details" blurb beneath senior official updateContactInfo(data); showElement(contactList.parentElement); - + + // Get the associated senior official with this federal agency let seniorOfficialId = data.id; let seniorOfficialName = [data.first_name, data.last_name].join(" "); - if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){ - // Clear the field if the SO doesn't exist - $seniorOfficial.val("").trigger("change"); - return; - } - - // Add the senior official to the dropdown. - // This format supports select2 - if we decide to convert this field in the future. - if ($seniorOfficial.find(`option[value='${seniorOfficialId}']`).length) { - // Select the value that is associated with the current Senior Official. - $seniorOfficial.val(seniorOfficialId).trigger("change"); - } else { - // Create a DOM Option that matches the desired Senior Official. Then append it and select it. - let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true); - $seniorOfficial.append(userOption).trigger("change"); + if ($seniorOfficial && $seniorOfficial.length > 0) { + // If the senior official is a dropdown field, edit that + updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName); + }else { + if (readonlySeniorOfficial) { + let seniorOfficialLink = `${seniorOfficialName}` + readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + } } }) .catch(error => console.error("Error fetching senior official: ", error)); } + function updateSeniorOfficialDropdown(dropdown, seniorOfficialId, seniorOfficialName) { + if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){ + // Clear the field if the SO doesn't exist + dropdown.val("").trigger("change"); + return; + } + + // Add the senior official to the dropdown. + // This format supports select2 - if we decide to convert this field in the future. + if (dropdown.find(`option[value='${seniorOfficialId}']`).length) { + // Select the value that is associated with the current Senior Official. + dropdown.val(seniorOfficialId).trigger("change"); + } else { + // Create a DOM Option that matches the desired Senior Official. Then append it and select it. + let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true); + dropdown.append(userOption).trigger("change"); + } + } + function handleStateTerritoryChange(stateTerritory, urbanizationField) { let selectedValue = stateTerritory.value; if (selectedValue === "PR") { diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 027ef4344..8dd0fcf14 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1853,6 +1853,125 @@ class DomainRequestsTable extends LoadTableBase { } } +class MembersTable extends LoadTableBase { + + constructor() { + super('.members__table', '.members__table-wrapper', '#members__search-field', '#members__search-field-submit', '.members__reset-search', '.members__reset-filters', '.members__no-data', '.members__no-search-results'); + } + /** + * Loads rows in the members list, as well as updates pagination around the members list + * based on the supplied attributes. + * @param {*} page - the page number of the results (starts with 1) + * @param {*} sortBy - the sort column option + * @param {*} order - the sort order {asc, desc} + * @param {*} scroll - control for the scrollToElement functionality + * @param {*} status - control for the status filter + * @param {*} searchTerm - the search term + * @param {*} portfolio - the portfolio id + */ + loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + + // --------- SEARCH + let searchParams = new URLSearchParams( + { + "page": page, + "sort_by": sortBy, + "order": order, + "status": status, + "search_term": searchTerm + } + ); + if (portfolio) + searchParams.append("portfolio", portfolio) + + + // --------- FETCH DATA + // fetch json of page of domais, given params + let baseUrl = document.getElementById("get_members_json_url"); + if (!baseUrl) { + return; + } + + let baseUrlValue = baseUrl.innerHTML; + if (!baseUrlValue) { + return; + } + + let url = `${baseUrlValue}?${searchParams.toString()}` //TODO: uncomment for search function + fetch(url) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error('Error in AJAX call: ' + data.error); + return; + } + + // handle the display of proper messaging in the event that no members exist in the list or search returns no results + this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); + + // identify the DOM element where the domain list will be inserted into the DOM + const memberList = document.querySelector('.members__table tbody'); + memberList.innerHTML = ''; + + data.members.forEach(member => { + // const actionUrl = domain.action_url; + const member_name = member.name; + const member_email = member.email; + const last_active = member.last_active; + const action_url = member.action_url; + const action_label = member.action_label; + const svg_icon = member.svg_icon; + + const row = document.createElement('tr'); + + let admin_tagHTML = ``; + if (member.is_admin) + admin_tagHTML = `Admin` + + row.innerHTML = ` + + ${member_email ? member_email : member_name} ${admin_tagHTML} + + + ${last_active} + + + + + ${action_label} ${member_name} + + + `; + memberList.appendChild(row); + }); + + // Do not scroll on first page load + if (scroll) + ScrollToElement('class', 'members'); + this.scrollToTable = true; + + // update pagination + this.updatePagination( + 'member', + '#members-pagination', + '#members-pagination .usa-pagination__counter', + '#members', + data.page, + data.num_pages, + data.has_previous, + data.has_next, + data.total, + ); + this.currentSortBy = sortBy; + this.currentOrder = order; + this.currentSearchTerm = searchTerm; + }) + .catch(error => console.error('Error fetching members:', error)); + } +} + /** * An IIFE that listens for DOM Content to be loaded, then executes. This function @@ -1926,6 +2045,23 @@ const utcDateString = (dateString) => { }; + +/** + * An IIFE that listens for DOM Content to be loaded, then executes. This function + * initializes the domains list and associated functionality on the home page of the app. + * + */ +document.addEventListener('DOMContentLoaded', function() { + const isMembersPage = document.querySelector("#members") + if (isMembersPage){ + const membersTable = new MembersTable(); + if (membersTable.tableWrapper) { + // Initial load + membersTable.loadTable(1); + } + } +}); + /** * An IIFE that displays confirmation modal on the user profile page */ diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index ef1a810ac..5cea72c4c 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -126,7 +126,8 @@ html[data-theme="light"] { body.dashboard, body.change-list, body.change-form, - .custom-admin-template, dt { + .custom-admin-template, + .dl-dja dt { color: var(--body-fg); } .usa-table td { @@ -155,7 +156,8 @@ html[data-theme="dark"] { body.dashboard, body.change-list, body.change-form, - .custom-admin-template, dt { + .custom-admin-template, + .dl-dja dt { color: var(--body-fg); } .usa-table td { @@ -453,7 +455,8 @@ details.dja-detail-table { background-color: var(--body-bg); .dja-details-summary { cursor: pointer; - color: var(--body-quiet-color); + color: var(--link-fg); + text-decoration: underline; } @media (max-width: 1024px){ @@ -893,23 +896,6 @@ div.dja__model-description{ } } -.vertical-separator { - min-height: 20px; - height: 100%; - width: 1px; - background-color: #d1d2d2; - vertical-align: middle -} - -.usa-summary-box_admin { - color: var(--body-fg); - border-color: var(--summary-box-border); - background-color: var(--summary-box-bg); - min-width: fit-content; - padding: .5rem; - border-radius: .25rem; -} - .text-faded { color: #{$dhs-gray-60}; } @@ -930,3 +916,16 @@ ul.add-list-reset { .dl-dja dt { font-size: 14px; } + +.domain-name-wrap { + white-space: normal; + word-wrap: break-word; + overflow: visible; + word-break: break-all; + max-width: 100%; +} + +.organization-admin-label { + font-weight: 600; + font-size: .8125rem; +} diff --git a/src/registrar/assets/sass/_theme/_alerts.scss b/src/registrar/assets/sass/_theme/_alerts.scss index 163f243d3..08404232e 100644 --- a/src/registrar/assets/sass/_theme/_alerts.scss +++ b/src/registrar/assets/sass/_theme/_alerts.scss @@ -1,3 +1,5 @@ +@use "base" as *; + // Fixes some font size disparities with the Figma // for usa-alert alert elements .usa-alert { @@ -22,3 +24,8 @@ margin-left: 0.5rem!important; } } + +// NOTE: !important is used because _font.scss overrides this +.usa-alert__body--widescreen { + max-width: $widescreen-max-width !important; +} diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 9d2ed4177..85f453dac 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -1,6 +1,8 @@ @use "uswds-core" as *; @use "cisa_colors" as *; +$widescreen-max-width: 1920px; + /* Styles for making visible to screen reader / AT users only. */ .sr-only { @include sr-only; @@ -102,6 +104,24 @@ body { } } +/* +This is a hack to keep the "Export" button on Domain Requests page inline +with the searchbar in widescreen mode. + +EXPLANATION: The existing frontend implementation puts the searchbar and export +button in two separate columns in a grid, which creates a solid wrap-around effect +for mobile devices. The searchbar had a max-width that exactly equaled the max width +of its parent column (for non-widescreens), so there wasn't any issue at this time of +implementation. +However, during immplementation of widescreen mode this small max-width caused the searchbar to +no longer fill its parent grid column for larger screen sizes, creating a visual gap between +it and the adjacent export button. To fix this, we will limit the width of the first +grid column to the max-width of the searchbar, which was calculated to be 33rem. +*/ +.section-outlined__search--widescreen { + max-width: 33rem; +} + .break-word { word-break: break-word; } @@ -222,6 +242,14 @@ abbr[title] { left: auto!important; } +.usa-banner__inner--widescreen { + max-width: $widescreen-max-width; +} + +.margin-right-neg-4px { + margin-right: -4px; +} + .break-word { word-break: break-word; } diff --git a/src/registrar/assets/sass/_theme/_containers.scss b/src/registrar/assets/sass/_theme/_containers.scss new file mode 100644 index 000000000..7473615ad --- /dev/null +++ b/src/registrar/assets/sass/_theme/_containers.scss @@ -0,0 +1,8 @@ +@use "uswds-core" as *; +@use "base" as *; + +//NOTE: !important is needed because it gets overriden by other .scss for footer nav +.grid-container--widescreen, +.usa-identifier__container--widescreen { + max-width: $widescreen-max-width !important; +} diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index 44c224aad..08e35b19f 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -68,21 +68,6 @@ legend.float-left-tablet + button.float-right-tablet { } } -// Custom style for disabled inputs -@media (prefers-color-scheme: light) { - .usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled { - background-color: #eeeeee; - color: #666666; - } -} - -@media (prefers-color-scheme: dark) { - .usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled { - background-color: var(--body-fg); - color: var(--close-button-hover-bg); - } -} - .read-only-label { font-size: size('body', 'sm'); color: color('primary-dark'); diff --git a/src/registrar/assets/sass/_theme/_header.scss b/src/registrar/assets/sass/_theme/_header.scss index d79774d98..53eab90d8 100644 --- a/src/registrar/assets/sass/_theme/_header.scss +++ b/src/registrar/assets/sass/_theme/_header.scss @@ -1,5 +1,6 @@ @use "uswds-core" as *; @use "cisa_colors" as *; +@use "base" as *; // Define some styles for the .gov header/logo .usa-logo button { @@ -127,3 +128,9 @@ } } } + +.usa-nav__inner--widescreen, +.usa-navbar--widescreen, +.usa-nav-container--widescreen { + max-width: $widescreen-max-width !important; +} diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index 5616b7509..8c38ae0b4 100644 --- a/src/registrar/assets/sass/_theme/styles.scss +++ b/src/registrar/assets/sass/_theme/styles.scss @@ -23,6 +23,7 @@ @forward "identifier"; @forward "header"; @forward "register-form"; +@forward "containers"; /*-------------------------------------------------- --- Admin ---------------------------------*/ diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index c5d4fa95d..523b802a6 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -246,6 +246,7 @@ TEMPLATES = [ "registrar.context_processors.org_user_status", "registrar.context_processors.add_path_to_context", "registrar.context_processors.portfolio_permissions", + "registrar.context_processors.is_widescreen_mode", ], }, }, @@ -478,7 +479,7 @@ class JsonServerFormatter(ServerFormatter): if not hasattr(record, "server_time"): record.server_time = self.formatTime(record, self.datefmt) - + log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record} return json.dumps(log_entry) @@ -724,6 +725,7 @@ ALLOWED_HOSTS = [ "getgov-stable.app.cloud.gov", "getgov-staging.app.cloud.gov", "getgov-development.app.cloud.gov", + "getgov-el.app.cloud.gov", "getgov-ad.app.cloud.gov", "getgov-ms.app.cloud.gov", "getgov-ag.app.cloud.gov", diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 9b9ed569e..3099f468f 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -22,17 +22,20 @@ from registrar.views.report_views import ( ExportDataTypeUser, ) -from registrar.views.domain_request import Step +# --jsons from registrar.views.domain_requests_json import get_domain_requests_json -from registrar.views.transfer_user import TransferUserView +from registrar.views.domains_json import get_domains_json +from registrar.views.portfolio_members_json import get_portfolio_members_json from registrar.views.utility.api_views import ( get_senior_official_from_federal_agency_json, get_federal_and_portfolio_types_from_federal_agency_json, + get_action_needed_email_for_user_json, ) -from registrar.views.domains_json import get_domains_json -from registrar.views.utility import always_404 -from api.views import available, get_current_federal, get_current_full +from registrar.views.domain_request import Step +from registrar.views.transfer_user import TransferUserView +from registrar.views.utility import always_404 +from api.views import available, rdap, get_current_federal, get_current_full DOMAIN_REQUEST_NAMESPACE = views.DomainRequestWizard.URL_NAMESPACE domain_request_urls = [ @@ -73,6 +76,16 @@ urlpatterns = [ views.PortfolioNoDomainsView.as_view(), name="no-portfolio-domains", ), + path( + "members/", + views.PortfolioMembersView.as_view(), + name="members", + ), + # path( + # "no-organization-members/", + # views.PortfolioNoMembersView.as_view(), + # name="no-portfolio-members", + # ), path( "requests/", views.PortfolioDomainRequestsView.as_view(), @@ -153,6 +166,11 @@ urlpatterns = [ get_federal_and_portfolio_types_from_federal_agency_json, name="get-federal-and-portfolio-types-from-federal-agency-json", ), + path( + "admin/api/get-action-needed-email-for-user-json/", + get_action_needed_email_for_user_json, + name="get-action-needed-email-for-user-json", + ), path("admin/", admin.site.urls), path( "reports/export_data_type_user/", @@ -169,6 +187,11 @@ urlpatterns = [ views.DomainRequestStatus.as_view(), name="domain-request-status", ), + path( + "domain-request/viewonly/", + views.PortfolioDomainRequestStatusViewOnly.as_view(), + name="domain-request-status-viewonly", + ), path( "domain-request//withdraw", views.DomainRequestWithdrawConfirmation.as_view(), @@ -183,6 +206,7 @@ urlpatterns = [ path("openid/", include("djangooidc.urls")), path("request/", include((domain_request_urls, DOMAIN_REQUEST_NAMESPACE))), path("api/v1/available/", available, name="available"), + path("api/v1/rdap/", rdap, name="rdap"), path("api/v1/get-report/current-federal", get_current_federal, name="get-current-federal"), path("api/v1/get-report/current-full", get_current_full, name="get-current-full"), path( @@ -264,6 +288,7 @@ urlpatterns = [ ), path("get-domains-json/", get_domains_json, name="get_domains_json"), path("get-domain-requests-json/", get_domain_requests_json, name="get_domain_requests_json"), + path("get-portfolio-members-json/", get_portfolio_members_json, name="get_portfolio_members_json"), ] # Djangooidc strips out context data from that context, so we define a custom error diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index 3ceb25af1..53f6e8ae7 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -94,3 +94,8 @@ def portfolio_permissions(request): except AttributeError: # Handles cases where request.user might not exist return portfolio_context + + +def is_widescreen_mode(request): + widescreen_paths = ["/domains/", "/requests/", "/members/"] + return {"is_widescreen_mode": any(path in request.path for path in widescreen_paths) or request.path == "/"} diff --git a/src/registrar/fixtures/fixtures_domains.py b/src/registrar/fixtures/fixtures_domains.py new file mode 100644 index 000000000..98f13cd43 --- /dev/null +++ b/src/registrar/fixtures/fixtures_domains.py @@ -0,0 +1,138 @@ +from datetime import timedelta +from django.utils import timezone +import logging +import random +from faker import Faker +from django.db import transaction + +from registrar.fixtures.fixtures_requests import DomainRequestFixture +from registrar.fixtures.fixtures_users import UserFixture +from registrar.models import User, DomainRequest +from registrar.models.domain import Domain + +fake = Faker() +logger = logging.getLogger(__name__) + + +class DomainFixture(DomainRequestFixture): + """Create two domains and permissions on them for each user. + One domain will have a past expiration date. + + Depends on fixtures_requests. + + Make sure this class' `load` method is called from `handle` + in management/commands/load.py, then use `./manage.py load` + to run this code. + """ + + @classmethod + def load(cls): + # Lumped under .atomic to ensure we don't make redundant DB calls. + # This bundles them all together, and then saves it in a single call. + with transaction.atomic(): + try: + # Get the usernames of users created in the UserFixture + created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF] + + # Filter users to only include those created by the fixture + users = list(User.objects.filter(username__in=created_usernames)) + except Exception as e: + logger.warning(e) + return + + # Approve each user associated with `in review` status domains + cls._approve_domain_requests(users) + + @staticmethod + def _generate_fake_expiration_date(days_in_future=365): + """Generates a fake expiration date between 1 and 365 days in the future.""" + current_date = timezone.now().date() # nosec + return current_date + timedelta(days=random.randint(1, days_in_future)) # nosec + + @staticmethod + def _generate_fake_expiration_date_in_past(): + """Generates a fake expiration date up to 365 days in the past.""" + current_date = timezone.now().date() # nosec + return current_date + timedelta(days=random.randint(-365, -1)) # nosec + + @classmethod + def _approve_request(cls, domain_request, users): + """Helper function to approve a domain request.""" + if not domain_request: + return None + + if domain_request.investigator is None: + # Assign random investigator if not already assigned + domain_request.investigator = random.choice(users) # nosec + + # Approve the domain request + domain_request.approve(send_email=False) + + return domain_request + + @classmethod + def _approve_domain_requests(cls, users): + """Approves one current and one expired request per user.""" + domain_requests_to_update = [] + expired_requests = [] + + for user in users: + # Get the latest and second-to-last domain requests + domain_requests = DomainRequest.objects.filter( + creator=user, status=DomainRequest.DomainRequestStatus.IN_REVIEW + ).order_by("-id")[:2] + + # Latest domain request + domain_request = domain_requests[0] if domain_requests else None + # Second-to-last domain request (expired) + domain_request_expired = domain_requests[1] if len(domain_requests) > 1 else None + + # Approve the current domain request + if domain_request: + cls._approve_request(domain_request, users) + domain_requests_to_update.append(domain_request) + + # Approve the expired domain request + if domain_request_expired: + cls._approve_request(domain_request_expired, users) + domain_requests_to_update.append(domain_request_expired) + expired_requests.append(domain_request_expired) + + # Perform bulk update for the domain requests + cls._bulk_update_requests(domain_requests_to_update) + + # Retrieve all domains associated with the domain requests + domains_to_update = Domain.objects.filter(domain_info__domain_request__in=domain_requests_to_update) + + # Loop through and update expiration dates for domains + for domain in domains_to_update: + domain_request = domain.domain_info.domain_request + + # Set the expiration date based on whether the request is expired + if domain_request in expired_requests: + domain.expiration_date = cls._generate_fake_expiration_date_in_past() + else: + domain.expiration_date = cls._generate_fake_expiration_date() + + # Perform bulk update for the domains + cls._bulk_update_domains(domains_to_update) + + @classmethod + def _bulk_update_requests(cls, domain_requests_to_update): + """Bulk update domain requests.""" + if domain_requests_to_update: + try: + DomainRequest.objects.bulk_update(domain_requests_to_update, ["status", "investigator"]) + logger.info(f"Successfully updated {len(domain_requests_to_update)} requests.") + except Exception as e: + logger.error(f"Unexpected error during requests bulk update: {e}") + + @classmethod + def _bulk_update_domains(cls, domains_to_update): + """Bulk update domains with expiration dates.""" + if domains_to_update: + try: + Domain.objects.bulk_update(domains_to_update, ["expiration_date"]) + logger.info(f"Successfully updated {len(domains_to_update)} domains.") + except Exception as e: + logger.error(f"Unexpected error during domains bulk update: {e}") diff --git a/src/registrar/fixtures/fixtures_portfolios.py b/src/registrar/fixtures/fixtures_portfolios.py new file mode 100644 index 000000000..2a391fb16 --- /dev/null +++ b/src/registrar/fixtures/fixtures_portfolios.py @@ -0,0 +1,125 @@ +import logging +import random +from faker import Faker +from django.db import transaction + +from registrar.models import User, DomainRequest, FederalAgency +from registrar.models.portfolio import Portfolio +from registrar.models.senior_official import SeniorOfficial + + +fake = Faker() +logger = logging.getLogger(__name__) + + +class PortfolioFixture: + """ + Creates 2 pre-defined portfolios with the infrastructure to add more. + + Make sure this class' `load` method is called from `handle` + in management/commands/load.py, then use `./manage.py load` + to run this code. + """ + + PORTFOLIOS = [ + { + "organization_name": "Hotel California", + }, + { + "organization_name": "Wish You Were Here", + }, + ] + + @classmethod + def fake_so(cls): + return { + "first_name": fake.first_name(), + "last_name": fake.last_name(), + "title": fake.job(), + "email": fake.ascii_safe_email(), + "phone": "201-555-5555", + } + + @classmethod + def _set_non_foreign_key_fields(cls, portfolio: Portfolio, portfolio_dict: dict): + """Helper method used by `load`.""" + portfolio.organization_type = ( + portfolio_dict["organization_type"] + if "organization_type" in portfolio_dict + else DomainRequest.OrganizationChoices.FEDERAL + ) + portfolio.notes = portfolio_dict["notes"] if "notes" in portfolio_dict else None + portfolio.address_line1 = ( + portfolio_dict["address_line1"] if "address_line1" in portfolio_dict else fake.street_address() + ) + portfolio.address_line2 = portfolio_dict["address_line2"] if "address_line2" in portfolio_dict else None + portfolio.city = portfolio_dict["city"] if "city" in portfolio_dict else fake.city() + portfolio.state_territory = ( + portfolio_dict["state_territory"] if "state_territory" in portfolio_dict else fake.state_abbr() + ) + portfolio.zipcode = portfolio_dict["zipcode"] if "zipcode" in portfolio_dict else fake.postalcode() + portfolio.urbanization = portfolio_dict["urbanization"] if "urbanization" in portfolio_dict else None + portfolio.security_contact_email = ( + portfolio_dict["security_contact_email"] if "security_contact_email" in portfolio_dict else fake.email() + ) + + @classmethod + def _set_foreign_key_fields(cls, portfolio: Portfolio, portfolio_dict: dict, user: User): + """Helper method used by `load`.""" + if not portfolio.senior_official: + if portfolio_dict.get("senior_official") is not None: + portfolio.senior_official, _ = SeniorOfficial.objects.get_or_create(**portfolio_dict["senior_official"]) + else: + portfolio.senior_official = SeniorOfficial.objects.create(**cls.fake_so()) + + if not portfolio.federal_agency: + if portfolio_dict.get("federal_agency") is not None: + portfolio.federal_agency, _ = FederalAgency.objects.get_or_create(name=portfolio_dict["federal_agency"]) + else: + federal_agencies = FederalAgency.objects.all() + # Random choice of agency for selects, used as placeholders for testing. + portfolio.federal_agency = random.choice(federal_agencies) # nosec + + @classmethod + def load(cls): + """Creates portfolios.""" + logger.info("Going to load %s portfolios" % len(cls.PORTFOLIOS)) + + # Lumped under .atomic to ensure we don't make redundant DB calls. + # This bundles them all together, and then saves it in a single call. + with transaction.atomic(): + try: + user = User.objects.all().last() + except Exception as e: + logger.warning(e) + return + + portfolios_to_create = [] + for portfolio_data in cls.PORTFOLIOS: + organization_name = portfolio_data["organization_name"] + + # Check if portfolio with the organization name already exists + if Portfolio.objects.filter(organization_name=organization_name).exists(): + logger.info( + f"Portfolio with organization name '{organization_name}' already exists, skipping creation." + ) + continue + + try: + portfolio = Portfolio( + creator=user, + organization_name=portfolio_data["organization_name"], + ) + cls._set_non_foreign_key_fields(portfolio, portfolio_data) + cls._set_foreign_key_fields(portfolio, portfolio_data, user) + portfolios_to_create.append(portfolio) + except Exception as e: + logger.warning(e) + + # Bulk create domain requests + if len(portfolios_to_create) > 0: + try: + Portfolio.objects.bulk_create(portfolios_to_create) + logger.info(f"Successfully created {len(portfolios_to_create)} portfolios") + except Exception as e: + logger.warning(f"Error bulk creating portfolios: {e}") diff --git a/src/registrar/fixtures/fixtures_requests.py b/src/registrar/fixtures/fixtures_requests.py new file mode 100644 index 000000000..f5b57491e --- /dev/null +++ b/src/registrar/fixtures/fixtures_requests.py @@ -0,0 +1,325 @@ +from datetime import timedelta +from django.utils import timezone +import logging +import random +from faker import Faker +from django.db import transaction + +from registrar.fixtures.fixtures_portfolios import PortfolioFixture +from registrar.fixtures.fixtures_users import UserFixture +from registrar.models import User, DomainRequest, DraftDomain, Contact, Website, FederalAgency +from registrar.models.portfolio import Portfolio +from registrar.models.suborganization import Suborganization + + +fake = Faker() +logger = logging.getLogger(__name__) + + +class DomainRequestFixture: + """ + Creates domain requests for each user in the database, + assign portfolios and suborgs. + + Creates 3 in_review requests, one for approving with an expired domain, + one for approving with a non-expired domain, and one for leaving in in_review. + + Depends on fixtures_portfolios and fixtures_suborganizations. + + Make sure this class' `load` method is called from `handle` + in management/commands/load.py, then use `./manage.py load` + to run this code. + """ + + # any fields not specified here will be filled in with fake data or defaults + # NOTE BENE: each fixture must have `organization_name` for uniqueness! + # Here is a more complete example as a template: + # { + # "status": "started", + # "organization_name": "Example - Just started", + # "generic_org_type": "federal", + # "federal_agency": None, + # "federal_type": None, + # "address_line1": None, + # "address_line2": None, + # "city": None, + # "state_territory": None, + # "zipcode": None, + # "urbanization": None, + # "purpose": None, + # "anything_else": None, + # "is_policy_acknowledged": None, + # "senior_official": None, + # "other_contacts": [], + # "current_websites": [], + # "alternative_domains": [], + # }, + DOMAINREQUESTS = [ + { + "status": DomainRequest.DomainRequestStatus.STARTED, + "organization_name": "Example - Finished but not submitted", + }, + { + "status": DomainRequest.DomainRequestStatus.SUBMITTED, + "organization_name": "Example - Submitted but pending investigation", + }, + { + "status": DomainRequest.DomainRequestStatus.IN_REVIEW, + "organization_name": "Example - In investigation", + }, + { + "status": DomainRequest.DomainRequestStatus.IN_REVIEW, + "organization_name": "Example - Approved", + }, + { + "status": DomainRequest.DomainRequestStatus.IN_REVIEW, + "organization_name": "Example - Approved, domain expired", + }, + { + "status": DomainRequest.DomainRequestStatus.WITHDRAWN, + "organization_name": "Example - Withdrawn", + }, + { + "status": DomainRequest.DomainRequestStatus.ACTION_NEEDED, + "organization_name": "Example - Action needed", + }, + { + "status": "rejected", + "organization_name": "Example - Rejected", + }, + ] + + @classmethod + def fake_contact(cls): + return { + "first_name": fake.first_name(), + "middle_name": None, + "last_name": fake.last_name(), + "title": fake.job(), + "email": fake.ascii_safe_email(), + "phone": "201-555-5555", + } + + @classmethod + def fake_dot_gov(cls): + return f"{fake.slug()}.gov" + + @classmethod + def fake_expiration_date(cls): + """Generates a fake expiration date between 0 and 1 year in the future.""" + current_date = timezone.now().date() + days_in_future = random.randint(0, 365) # nosec + return current_date + timedelta(days=days_in_future) + + @classmethod + def _set_non_foreign_key_fields(cls, request: DomainRequest, request_dict: dict): + """Helper method used by `load`.""" + request.status = request_dict["status"] if "status" in request_dict else "started" + + # TODO for a future ticket: Allow for more than just "federal" here + request.generic_org_type = request_dict["generic_org_type"] if "generic_org_type" in request_dict else "federal" + if request.status != "started": + request.last_submitted_date = fake.date() + request.federal_type = ( + request_dict["federal_type"] + if "federal_type" in request_dict + else random.choice(["executive", "judicial", "legislative"]) # nosec + ) + request.address_line1 = ( + request_dict["address_line1"] if "address_line1" in request_dict else fake.street_address() + ) + request.address_line2 = request_dict["address_line2"] if "address_line2" in request_dict else None + request.city = request_dict["city"] if "city" in request_dict else fake.city() + request.state_territory = ( + request_dict["state_territory"] if "state_territory" in request_dict else fake.state_abbr() + ) + request.zipcode = request_dict["zipcode"] if "zipcode" in request_dict else fake.postalcode() + request.urbanization = request_dict["urbanization"] if "urbanization" in request_dict else None + request.purpose = request_dict["purpose"] if "purpose" in request_dict else fake.paragraph() + request.has_cisa_representative = ( + request_dict["has_cisa_representative"] if "has_cisa_representative" in request_dict else True + ) + request.cisa_representative_email = ( + request_dict["cisa_representative_email"] if "cisa_representative_email" in request_dict else fake.email() + ) + request.cisa_representative_first_name = ( + request_dict["cisa_representative_first_name"] + if "cisa_representative_first_name" in request_dict + else fake.first_name() + ) + request.cisa_representative_last_name = ( + request_dict["cisa_representative_last_name"] + if "cisa_representative_last_name" in request_dict + else fake.last_name() + ) + request.has_anything_else_text = ( + request_dict["has_anything_else_text"] if "has_anything_else_text" in request_dict else True + ) + request.anything_else = request_dict["anything_else"] if "anything_else" in request_dict else fake.paragraph() + request.is_policy_acknowledged = ( + request_dict["is_policy_acknowledged"] if "is_policy_acknowledged" in request_dict else True + ) + + @classmethod + def _set_foreign_key_fields(cls, request: DomainRequest, request_dict: dict, user: User): + """Helper method used by `load`.""" + request.investigator = cls._get_investigator(request, request_dict, user) + request.senior_official = cls._get_senior_official(request, request_dict) + request.requested_domain = cls._get_requested_domain(request, request_dict) + request.federal_agency = cls._get_federal_agency(request, request_dict) + request.portfolio = cls._get_portfolio(request, request_dict) + request.sub_organization = cls._get_sub_organization(request, request_dict) + + @classmethod + def _get_investigator(cls, request: DomainRequest, request_dict: dict, user: User): + if not request.investigator: + return User.objects.get(username=user.username) if "investigator" in request_dict else None + return request.investigator + + @classmethod + def _get_senior_official(cls, request: DomainRequest, request_dict: dict): + if not request.senior_official: + if "senior_official" in request_dict and request_dict["senior_official"] is not None: + return Contact.objects.get_or_create(**request_dict["senior_official"])[0] + return Contact.objects.create(**cls.fake_contact()) + return request.senior_official + + @classmethod + def _get_requested_domain(cls, request: DomainRequest, request_dict: dict): + if not request.requested_domain: + if "requested_domain" in request_dict and request_dict["requested_domain"] is not None: + return DraftDomain.objects.get_or_create(name=request_dict["requested_domain"])[0] + return DraftDomain.objects.create(name=cls.fake_dot_gov()) + return request.requested_domain + + @classmethod + def _get_federal_agency(cls, request: DomainRequest, request_dict: dict): + if not request.federal_agency: + if "federal_agency" in request_dict and request_dict["federal_agency"] is not None: + return FederalAgency.objects.get_or_create(name=request_dict["federal_agency"])[0] + return random.choice(FederalAgency.objects.all()) # nosec + return request.federal_agency + + @classmethod + def _get_portfolio(cls, request: DomainRequest, request_dict: dict): + if not request.portfolio: + if "portfolio" in request_dict and request_dict["portfolio"] is not None: + return Portfolio.objects.get_or_create(name=request_dict["portfolio"])[0] + return cls._get_random_portfolio() + return request.portfolio + + @classmethod + def _get_sub_organization(cls, request: DomainRequest, request_dict: dict): + if not request.sub_organization: + if "sub_organization" in request_dict and request_dict["sub_organization"] is not None: + return Suborganization.objects.get_or_create(name=request_dict["sub_organization"])[0] + return cls._get_random_sub_organization() + return request.sub_organization + + @classmethod + def _get_random_portfolio(cls): + try: + organization_names = [portfolio["organization_name"] for portfolio in PortfolioFixture.PORTFOLIOS] + + portfolio_options = Portfolio.objects.filter(organization_name__in=organization_names) + return random.choice(portfolio_options) if portfolio_options.exists() else None # nosec + except Exception as e: + logger.warning(f"Expected fixture portfolio, did not find it: {e}") + return None + + @classmethod + def _get_random_sub_organization(cls): + try: + suborg_options = [Suborganization.objects.first(), Suborganization.objects.last()] + return random.choice(suborg_options) # nosec + except Exception as e: + logger.warning(f"Expected fixture sub_organization, did not find it: {e}") + return None + + @classmethod + def _set_many_to_many_relations(cls, request: DomainRequest, request_dict: dict): + """Helper method used by `load`.""" + if "other_contacts" in request_dict: + for contact in request_dict["other_contacts"]: + request.other_contacts.add(Contact.objects.get_or_create(**contact)[0]) + elif not request.other_contacts.exists(): + other_contacts = [ + Contact.objects.create(**cls.fake_contact()) for _ in range(random.randint(1, 3)) # nosec + ] + request.other_contacts.add(*other_contacts) + + if "current_websites" in request_dict: + for website in request_dict["current_websites"]: + request.current_websites.add(Website.objects.get_or_create(website=website)[0]) + elif not request.current_websites.exists(): + current_websites = [ + Website.objects.create(website=fake.uri()) for _ in range(random.randint(0, 3)) # nosec + ] + request.current_websites.add(*current_websites) + + if "alternative_domains" in request_dict: + for domain in request_dict["alternative_domains"]: + request.alternative_domains.add(Website.objects.get_or_create(website=domain)[0]) + elif not request.alternative_domains.exists(): + alternative_domains = [ + Website.objects.create(website=cls.fake_dot_gov()) for _ in range(random.randint(0, 3)) # nosec + ] + request.alternative_domains.add(*alternative_domains) + + @classmethod + def load(cls): + """Creates domain requests for each user in the database.""" + logger.info("Going to load %s domain requests" % len(cls.DOMAINREQUESTS)) + + # Lumped under .atomic to ensure we don't make redundant DB calls. + # This bundles them all together, and then saves it in a single call. + with transaction.atomic(): + try: + # Get the usernames of users created in the UserFixture + created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF] + + # Filter users to only include those created by the fixture + users = list(User.objects.filter(username__in=created_usernames)) + except Exception as e: + logger.warning(e) + return + + cls._create_domain_requests(users) + + @classmethod + def _create_domain_requests(cls, users): + """Creates DomainRequests given a list of users.""" + domain_requests_to_create = [] + for user in users: + for request_data in cls.DOMAINREQUESTS: + # Prepare DomainRequest objects + try: + domain_request = DomainRequest( + creator=user, + organization_name=request_data["organization_name"], + ) + cls._set_non_foreign_key_fields(domain_request, request_data) + cls._set_foreign_key_fields(domain_request, request_data, user) + domain_requests_to_create.append(domain_request) + except Exception as e: + logger.warning(e) + + # Bulk create domain requests + cls._bulk_create_requests(domain_requests_to_create) + + # Now many-to-many relationships + for domain_request in domain_requests_to_create: + try: + cls._set_many_to_many_relations(domain_request, request_data) + except Exception as e: + logger.warning(e) + + @classmethod + def _bulk_create_requests(cls, domain_requests_to_create): + """Bulk create domain requests.""" + if len(domain_requests_to_create) > 0: + try: + DomainRequest.objects.bulk_create(domain_requests_to_create) + logger.info(f"Successfully created {len(domain_requests_to_create)} requests.") + except Exception as e: + logger.error(f"Unexpected error during requests bulk creation: {e}") diff --git a/src/registrar/fixtures/fixtures_suborganizations.py b/src/registrar/fixtures/fixtures_suborganizations.py new file mode 100644 index 000000000..af7e02804 --- /dev/null +++ b/src/registrar/fixtures/fixtures_suborganizations.py @@ -0,0 +1,87 @@ +import logging +from faker import Faker +from django.db import transaction + +from registrar.models.portfolio import Portfolio +from registrar.models.suborganization import Suborganization + + +fake = Faker() +logger = logging.getLogger(__name__) + + +class SuborganizationFixture: + """ + Creates 2 pre-defined suborg with the infrastructure to add more. + + Depends on fixtures_portfolios. + + Make sure this class' `load` method is called from `handle` + in management/commands/load.py, then use `./manage.py load` + to run this code. + """ + + SUBORGS = [ + { + "name": "Take it Easy", + }, + { + "name": "Welcome to the Machine", + }, + ] + + @classmethod + def load(cls): + """Creates suborganizations.""" + logger.info(f"Going to load {len(cls.SUBORGS)} suborgs") + + with transaction.atomic(): + portfolios = cls._get_portfolios() + if not portfolios: + return + + suborgs_to_create = cls._prepare_suborgs_to_create(portfolios) + cls._bulk_create_suborgs(suborgs_to_create) + + @classmethod + def _get_portfolios(cls): + """Fetches portfolios with organization_name 'Hotel California' and 'Wish You Were Here' + and logs warnings if not found.""" + try: + portfolio1 = Portfolio.objects.filter(organization_name="Hotel California").first() + portfolio2 = Portfolio.objects.filter(organization_name="Wish You Were Here").first() + + if not portfolio1 or not portfolio2: + logger.warning("One or both portfolios not found.") + return None + return portfolio1, portfolio2 + except Exception as e: + logger.warning(f"Error fetching portfolios: {e}") + return None + + @classmethod + def _prepare_suborgs_to_create(cls, portfolios): + """Prepares a list of suborganizations to create, avoiding duplicates.""" + portfolio1, portfolio2 = portfolios + suborgs_to_create = [] + + try: + if not Suborganization.objects.filter(name=cls.SUBORGS[0]["name"]).exists(): + suborgs_to_create.append(Suborganization(portfolio=portfolio1, name=cls.SUBORGS[0]["name"])) + + if not Suborganization.objects.filter(name=cls.SUBORGS[1]["name"]).exists(): + suborgs_to_create.append(Suborganization(portfolio=portfolio2, name=cls.SUBORGS[1]["name"])) + except Exception as e: + logger.warning(f"Error creating suborg objects: {e}") + + return suborgs_to_create + + @classmethod + def _bulk_create_suborgs(cls, suborgs_to_create): + """Bulk creates suborganizations and logs success or errors.""" + if suborgs_to_create: + try: + Suborganization.objects.bulk_create(suborgs_to_create) + logger.info(f"Successfully created {len(suborgs_to_create)} suborgs") + except Exception as e: + logger.warning(f"Error bulk creating suborgs: {e}") diff --git a/src/registrar/fixtures/fixtures_user_portfolio_permissions.py b/src/registrar/fixtures/fixtures_user_portfolio_permissions.py new file mode 100644 index 000000000..3c64eb6b5 --- /dev/null +++ b/src/registrar/fixtures/fixtures_user_portfolio_permissions.py @@ -0,0 +1,86 @@ +import logging +from faker import Faker +from django.db import transaction + +from registrar.fixtures.fixtures_portfolios import PortfolioFixture +from registrar.fixtures.fixtures_users import UserFixture +from registrar.models import User +from registrar.models.portfolio import Portfolio +from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices + +fake = Faker() +logger = logging.getLogger(__name__) + + +class UserPortfolioPermissionFixture: + """Create user portfolio permissions for each user. + Each user will be admin on 2 portfolios. + + Depends on fixture_portfolios""" + + @classmethod + def load(cls): + logger.info("Going to set user portfolio permissions") + + # Lumped under .atomic to ensure we don't make redundant DB calls. + # This bundles them all together, and then saves it in a single call. + with transaction.atomic(): + try: + # Get the usernames of users created in the UserFixture + created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF] + + # Filter users to only include those created by the fixture + users = list(User.objects.filter(username__in=created_usernames)) + + organization_names = [portfolio["organization_name"] for portfolio in PortfolioFixture.PORTFOLIOS] + + portfolios = list(Portfolio.objects.filter(organization_name__in=organization_names)) + + if not users: + logger.warning("User fixtures missing.") + return + + if not portfolios: + logger.warning("Portfolio fixtures missing.") + return + + except Exception as e: + logger.warning(e) + return + + user_portfolio_permissions_to_create = [] + for user in users: + for portfolio in portfolios: + try: + if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists(): + user_portfolio_permission = UserPortfolioPermission( + user=user, + portfolio=portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + user_portfolio_permissions_to_create.append(user_portfolio_permission) + else: + logger.info( + f"Permission exists for user '{user.username}' " + f"on portfolio '{portfolio.organization_name}'." + ) + except Exception as e: + logger.warning(e) + + # Bulk create permissions + cls._bulk_create_permissions(user_portfolio_permissions_to_create) + + @classmethod + def _bulk_create_permissions(cls, user_portfolio_permissions_to_create): + """Bulk creates permissions and logs success or errors.""" + if user_portfolio_permissions_to_create: + try: + UserPortfolioPermission.objects.bulk_create(user_portfolio_permissions_to_create) + logger.info( + f"Successfully created {len(user_portfolio_permissions_to_create)} user portfolio permissions." + ) + except Exception as e: + logger.error(f"Unexpected error during portfolio permission bulk creation: {e}") + else: + logger.info("No new user portfolio permissions to create.") diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures/fixtures_users.py similarity index 67% rename from src/registrar/fixtures_users.py rename to src/registrar/fixtures/fixtures_users.py index 7fbf41223..91b35f854 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures/fixtures_users.py @@ -23,129 +23,123 @@ class UserFixture: """ ADMINS = [ - { - "username": "43a7fa8d-0550-4494-a6fe-81500324d590", - "first_name": "Jyoti", - "last_name": "Bock", - "email": "jyotibock@truss.works", - }, { "username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf", "first_name": "Aditi", "last_name": "Green", "email": "aditidevelops+01@gmail.com", + "title": "Positive vibes", }, { "username": "be17c826-e200-4999-9389-2ded48c43691", "first_name": "Matthew", "last_name": "Spence", + "title": "Hollywood hair", }, { "username": "5f283494-31bd-49b5-b024-a7e7cae00848", "first_name": "Rachid", "last_name": "Mrad", "email": "rachid.mrad@associates.cisa.dhs.gov", + "title": "Common pirate", }, { "username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74", "first_name": "Alysia", "last_name": "Broddrick", "email": "abroddrick@truss.works", + "title": "I drink coffee and know things", }, { "username": "8f8e7293-17f7-4716-889b-1990241cbd39", "first_name": "Katherine", "last_name": "Osos", "email": "kosos@truss.works", + "title": "Grove keeper", }, { "username": "70488e0a-e937-4894-a28c-16f5949effd4", "first_name": "Gaby", "last_name": "DiSarli", "email": "gaby@truss.works", + "title": "De Stijl", }, { "username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c", "first_name": "Cameron", "last_name": "Dixon", "email": "cameron.dixon@cisa.dhs.gov", - }, - { - "username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea", - "first_name": "Ryan", - "last_name": "Brooks", + "title": "Product owner", }, { "username": "30001ee7-0467-4df2-8db2-786e79606060", "first_name": "Zander", "last_name": "Adkinson", + "title": "ACME specialist", }, { "username": "2bf518c2-485a-4c42-ab1a-f5a8b0a08484", "first_name": "Paul", "last_name": "Kuykendall", + "title": "Dr. Silvertongue", }, { "username": "2a88a97b-be96-4aad-b99e-0b605b492c78", "first_name": "Rebecca", "last_name": "Hsieh", "email": "rebecca.hsieh@truss.works", + "title": "Catlady", }, { "username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52", "first_name": "David", "last_name": "Kennedy", "email": "david.kennedy@ecstech.com", + "title": "Mean lean coding machine", }, { "username": "f14433d8-f0e9-41bf-9c72-b99b110e665d", "first_name": "Nicolle", "last_name": "LeClair", "email": "nicolle.leclair@ecstech.com", + "title": "Nightowl", }, { "username": "24840450-bf47-4d89-8aa9-c612fe68f9da", "first_name": "Erin", "last_name": "Song", + "title": "Catlady 2", }, { "username": "e0ea8b94-6e53-4430-814a-849a7ca45f21", "first_name": "Kristina", "last_name": "Yin", + "title": "Hufflepuff prefect", }, { "username": "ac49d7c1-368a-4e6b-8f1d-60250e20a16f", "first_name": "Vicky", "last_name": "Chin", "email": "szu.chin@associates.cisa.dhs.gov", + "title": "Ze whip", }, { "username": "66bb1a5a-a091-4d7f-a6cf-4d772b4711c7", "first_name": "Christina", "last_name": "Burnett", "email": "christina.burnett@cisa.dhs.gov", - }, - { - "username": "012f844d-8a0f-4225-9d82-cbf87bff1d3e", - "first_name": "Riley", - "last_name": "Orr", - "email": "riley+320@truss.works", + "title": "Groovy", }, { "username": "76612d84-66b0-4ae9-9870-81e98b9858b6", "first_name": "Anna", "last_name": "Gingle", "email": "annagingle@truss.works", + "title": "Sweetwater sailor", }, ] STAFF = [ - { - "username": "a5906815-dd80-4c64-aebe-2da6a4c9d7a4", - "first_name": "Jyoti-Analyst", - "last_name": "Bock-Analyst", - "email": "jyotibock+1@truss.works", - }, { "username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54", "first_name": "Aditi-Analyst", @@ -231,18 +225,6 @@ class UserFixture: "last_name": "Burnett-Analyst", "email": "christina.burnett@gwe.cisa.dhs.gov", }, - { - "username": "d9839768-0c17-4fa2-9c8e-36291eef5c11", - "first_name": "Alex-Analyst", - "last_name": "Mcelya-Analyst", - "email": "ALEXANDER.MCELYA@cisa.dhs.gov", - }, - { - "username": "082a066f-e0a4-45f6-8672-4343a1208a36", - "first_name": "Riley-Analyst", - "last_name": "Orr-Analyst", - "email": "riley+321@truss.works", - }, { "username": "e1e350b1-cfc1-4753-a6cb-3ae6d912f99c", "first_name": "Anna-Analyst", @@ -254,29 +236,61 @@ class UserFixture: # Additional emails to add to the AllowedEmail whitelist. ADDITIONAL_ALLOWED_EMAILS: list[str] = ["davekenn4242@gmail.com", "rachid_mrad@hotmail.com"] + @classmethod def load_users(cls, users, group_name, are_superusers=False): - logger.info(f"Going to load {len(users)} users in group {group_name}") - for user_data in users: + """Loads the users into the database and assigns them to the specified group.""" + logger.info(f"Going to load {len(users)} users for group {group_name}") + + group = UserGroup.objects.get(name=group_name) + + # Prepare sets of existing usernames and IDs in one query + user_identifiers = [(user.get("username"), user.get("id")) for user in users] + existing_users = User.objects.filter( + username__in=[user[0] for user in user_identifiers] + [user[1] for user in user_identifiers] + ).values_list("username", "id") + + existing_usernames = set(user[0] for user in existing_users) + existing_user_ids = set(user[1] for user in existing_users) + + # Filter out users with existing IDs or usernames + new_users = [ + User( + id=user_data.get("id"), + first_name=user_data.get("first_name"), + last_name=user_data.get("last_name"), + username=user_data.get("username"), + email=user_data.get("email", ""), + title=user_data.get("title", "Peon"), + phone=user_data.get("phone", "2022222222"), + is_active=user_data.get("is_active", True), + is_staff=True, + is_superuser=are_superusers, + ) + for user_data in users + if user_data.get("username") not in existing_usernames and user_data.get("id") not in existing_user_ids + ] + + # Perform bulk creation for new users + if new_users: try: - user, _ = User.objects.get_or_create(username=user_data["username"]) - user.is_superuser = are_superusers - user.first_name = user_data["first_name"] - user.last_name = user_data["last_name"] - if "email" in user_data: - user.email = user_data["email"] - user.is_staff = True - user.is_active = True - # This verification type will get reverted to "regular" (or whichever is applicables) - # once the user logs in for the first time (as they then got verified through different means). - # In the meantime, we can still describe how the user got here in the first place. - user.verification_type = User.VerificationTypeChoices.FIXTURE_USER - group = UserGroup.objects.get(name=group_name) - user.groups.add(group) - user.save() - logger.debug(f"User object created for {user_data['first_name']}") + User.objects.bulk_create(new_users) + logger.info(f"Created {len(new_users)} new users.") except Exception as e: - logger.warning(e) - logger.info(f"All users in group {group_name} loaded.") + logger.error(f"Unexpected error during user bulk creation: {e}") + else: + logger.info("No new users to create.") + + # Get all users to be updated (both new and existing) + created_or_existing_users = User.objects.filter(username__in=[user.get("username") for user in users]) + + # Filter out users who are already in the group + users_not_in_group = created_or_existing_users.exclude(groups__id=group.id) + + # Add only users who are not already in the group + if users_not_in_group.exists(): + group.user_set.add(*users_not_in_group) + + logger.info(f"Users loaded for group {group_name}.") def load_allowed_emails(cls, users, additional_emails): """Populates a whitelist of allowed emails (as defined in this list)""" @@ -284,37 +298,33 @@ class UserFixture: if additional_emails: logger.info(f"Going to load {len(additional_emails)} additional allowed emails") - # Load user emails - allowed_emails = [] + existing_emails = set(AllowedEmail.objects.values_list("email", flat=True)) + new_allowed_emails = [] + for user_data in users: user_email = user_data.get("email") - if user_email and user_email not in allowed_emails: - allowed_emails.append(AllowedEmail(email=user_email)) - else: - first_name = user_data.get("first_name") - last_name = user_data.get("last_name") - logger.warning(f"Could not add email to whitelist for {first_name} {last_name}.") + if user_email and user_email not in existing_emails: + new_allowed_emails.append(AllowedEmail(email=user_email)) - # Load additional emails - allowed_emails.extend([AllowedEmail(email=email) for email in additional_emails]) + # Load additional emails, only if they don't exist already + for email in additional_emails: + if email not in existing_emails: + new_allowed_emails.append(AllowedEmail(email=email)) - if allowed_emails: - AllowedEmail.objects.bulk_create(allowed_emails) - logger.info(f"Loaded {len(allowed_emails)} allowed emails") + if new_allowed_emails: + try: + AllowedEmail.objects.bulk_create(new_allowed_emails) + logger.info(f"Loaded {len(new_allowed_emails)} allowed emails") + except Exception as e: + logger.error(f"Unexpected error during allowed emails bulk creation: {e}") else: logger.info("No allowed emails to load") @classmethod def load(cls): - # Lumped under .atomic to ensure we don't make redundant DB calls. - # This bundles them all together, and then saves it in a single call. - # This is slightly different then bulk_create or bulk_update, in that - # you still get the same behaviour of .save(), but those incremental - # steps now do not need to close/reopen a db connection, - # instead they share one. with transaction.atomic(): - cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True) - cls.load_users(cls, cls.STAFF, "cisa_analysts_group") + cls.load_users(cls.ADMINS, "full_access_group", are_superusers=True) + cls.load_users(cls.STAFF, "cisa_analysts_group") # Combine ADMINS and STAFF lists all_users = cls.ADMINS + cls.STAFF diff --git a/src/registrar/fixtures_domain_requests.py b/src/registrar/fixtures_domain_requests.py deleted file mode 100644 index 44dd13e4c..000000000 --- a/src/registrar/fixtures_domain_requests.py +++ /dev/null @@ -1,236 +0,0 @@ -import logging -import random -from faker import Faker -from django.db import transaction - -from registrar.models import User, DomainRequest, DraftDomain, Contact, Website, FederalAgency - -fake = Faker() -logger = logging.getLogger(__name__) - - -class DomainRequestFixture: - """ - Load domain requests into the database. - - Make sure this class' `load` method is called from `handle` - in management/commands/load.py, then use `./manage.py load` - to run this code. - """ - - # any fields not specified here will be filled in with fake data or defaults - # NOTE BENE: each fixture must have `organization_name` for uniqueness! - # Here is a more complete example as a template: - # { - # "status": "started", - # "organization_name": "Example - Just started", - # "generic_org_type": "federal", - # "federal_agency": None, - # "federal_type": None, - # "address_line1": None, - # "address_line2": None, - # "city": None, - # "state_territory": None, - # "zipcode": None, - # "urbanization": None, - # "purpose": None, - # "anything_else": None, - # "is_policy_acknowledged": None, - # "senior_official": None, - # "other_contacts": [], - # "current_websites": [], - # "alternative_domains": [], - # }, - DA = [ - { - "status": DomainRequest.DomainRequestStatus.STARTED, - "organization_name": "Example - Finished but not submitted", - }, - { - "status": DomainRequest.DomainRequestStatus.SUBMITTED, - "organization_name": "Example - Submitted but pending investigation", - }, - { - "status": DomainRequest.DomainRequestStatus.IN_REVIEW, - "organization_name": "Example - In investigation", - }, - { - "status": DomainRequest.DomainRequestStatus.IN_REVIEW, - "organization_name": "Example - Approved", - }, - { - "status": DomainRequest.DomainRequestStatus.WITHDRAWN, - "organization_name": "Example - Withdrawn", - }, - { - "status": DomainRequest.DomainRequestStatus.ACTION_NEEDED, - "organization_name": "Example - Action needed", - }, - { - "status": "rejected", - "organization_name": "Example - Rejected", - }, - ] - - @classmethod - def fake_contact(cls): - return { - "first_name": fake.first_name(), - "middle_name": None, - "last_name": fake.last_name(), - "title": fake.job(), - "email": fake.ascii_safe_email(), - "phone": "201-555-5555", - } - - @classmethod - def fake_dot_gov(cls): - return f"{fake.slug()}.gov" - - @classmethod - 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" - - # TODO for a future ticket: Allow for more than just "federal" here - da.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal" - da.last_submitted_date = fake.date() - da.federal_type = ( - app["federal_type"] - if "federal_type" in app - else random.choice(["executive", "judicial", "legislative"]) # nosec - ) - da.address_line1 = app["address_line1"] if "address_line1" in app else fake.street_address() - da.address_line2 = app["address_line2"] if "address_line2" in app else None - da.city = app["city"] if "city" in app else fake.city() - da.state_territory = app["state_territory"] if "state_territory" in app else fake.state_abbr() - da.zipcode = app["zipcode"] if "zipcode" in app else fake.postalcode() - da.urbanization = app["urbanization"] if "urbanization" in app else None - da.purpose = app["purpose"] if "purpose" in app else fake.paragraph() - da.anything_else = app["anything_else"] if "anything_else" in app else None - da.is_policy_acknowledged = app["is_policy_acknowledged"] if "is_policy_acknowledged" in app else True - - @classmethod - def _set_foreign_key_fields(cls, da: DomainRequest, app: dict, user: User): - """Helper method used by `load`.""" - if not da.investigator: - da.investigator = User.objects.get(username=user.username) if "investigator" in app else None - - if not da.senior_official: - if "senior_official" in app and app["senior_official"] is not None: - da.senior_official, _ = Contact.objects.get_or_create(**app["senior_official"]) - else: - da.senior_official = Contact.objects.create(**cls.fake_contact()) - - if not da.requested_domain: - if "requested_domain" in app and app["requested_domain"] is not None: - da.requested_domain, _ = DraftDomain.objects.get_or_create(name=app["requested_domain"]) - else: - da.requested_domain = DraftDomain.objects.create(name=cls.fake_dot_gov()) - if not da.federal_agency: - if "federal_agency" in app and app["federal_agency"] is not None: - da.federal_agency, _ = FederalAgency.objects.get_or_create(name=app["federal_agency"]) - else: - federal_agencies = FederalAgency.objects.all() - # Random choice of agency for selects, used as placeholders for testing. - da.federal_agency = random.choice(federal_agencies) # nosec - - @classmethod - def _set_many_to_many_relations(cls, da: DomainRequest, app: dict): - """Helper method used by `load`.""" - if "other_contacts" in app: - for contact in app["other_contacts"]: - da.other_contacts.add(Contact.objects.get_or_create(**contact)[0]) - elif not da.other_contacts.exists(): - other_contacts = [ - Contact.objects.create(**cls.fake_contact()) for _ in range(random.randint(0, 3)) # nosec - ] - da.other_contacts.add(*other_contacts) - - if "current_websites" in app: - for website in app["current_websites"]: - da.current_websites.add(Website.objects.get_or_create(website=website)[0]) - elif not da.current_websites.exists(): - current_websites = [ - Website.objects.create(website=fake.uri()) for _ in range(random.randint(0, 3)) # nosec - ] - da.current_websites.add(*current_websites) - - if "alternative_domains" in app: - for domain in app["alternative_domains"]: - da.alternative_domains.add(Website.objects.get_or_create(website=domain)[0]) - elif not da.alternative_domains.exists(): - alternative_domains = [ - Website.objects.create(website=cls.fake_dot_gov()) for _ in range(random.randint(0, 3)) # nosec - ] - da.alternative_domains.add(*alternative_domains) - - @classmethod - def load(cls): - """Creates domain requests for each user in the database.""" - logger.info("Going to load %s domain requests" % len(cls.DA)) - try: - users = list(User.objects.all()) # force evaluation to catch db errors - except Exception as e: - logger.warning(e) - return - - # Lumped under .atomic to ensure we don't make redundant DB calls. - # This bundles them all together, and then saves it in a single call. - with transaction.atomic(): - cls._create_domain_requests(users) - - @classmethod - def _create_domain_requests(cls, users): - """Creates DomainRequests given a list of users""" - for user in users: - logger.debug("Loading domain requests for %s" % user) - for app in cls.DA: - try: - da, _ = DomainRequest.objects.get_or_create( - creator=user, - organization_name=app["organization_name"], - ) - cls._set_non_foreign_key_fields(da, app) - cls._set_foreign_key_fields(da, app, user) - da.save() - cls._set_many_to_many_relations(da, app) - except Exception as e: - logger.warning(e) - - -class DomainFixture(DomainRequestFixture): - """Create one domain and permissions on it for each user.""" - - @classmethod - def load(cls): - try: - users = list(User.objects.all()) # force evaluation to catch db errors - except Exception as e: - logger.warning(e) - return - - # Lumped under .atomic to ensure we don't make redundant DB calls. - # This bundles them all together, and then saves it in a single call. - with transaction.atomic(): - # approve each user associated with `in review` status domains - DomainFixture._approve_domain_requests(users) - - @staticmethod - def _approve_domain_requests(users): - """Approves all provided domain requests if they are in the state in_review""" - for user in users: - domain_request = DomainRequest.objects.filter( - creator=user, status=DomainRequest.DomainRequestStatus.IN_REVIEW - ).last() - logger.debug(f"Approving {domain_request} for {user}") - - # All approvals require an investigator, so if there is none, - # assign one. - if domain_request.investigator is None: - # All "users" in fixtures have admin perms per prior config. - # No need to check for that. - domain_request.investigator = random.choice(users) # nosec - - domain_request.approve(send_email=False) - domain_request.save() diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index 2ae50f908..ba3c37e1e 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -4,7 +4,6 @@ from itertools import zip_longest from typing import Callable from django.db.models.fields.related import ForeignObjectRel from django import forms - from registrar.models import DomainRequest, Contact @@ -278,3 +277,15 @@ class BaseYesNoForm(RegistrarForm): # No pre-selection for new domain requests initial_value = self.form_is_checked if self.domain_request else None return initial_value + + +def request_step_list(request_wizard): + """Dynamically generated list of steps in the form wizard.""" + step_list = [] + for step in request_wizard.StepEnum: + condition = request_wizard.WIZARD_CONDITIONS.get(step, True) + if callable(condition): + condition = condition(request_wizard) + if condition: + step_list.append(step) + return step_list diff --git a/src/registrar/management/commands/load.py b/src/registrar/management/commands/load.py index aac5eade1..a4cdb1ed5 100644 --- a/src/registrar/management/commands/load.py +++ b/src/registrar/management/commands/load.py @@ -1,11 +1,13 @@ import logging from django.core.management.base import BaseCommand -from auditlog.context import disable_auditlog # type: ignore - - -from registrar.fixtures_users import UserFixture -from registrar.fixtures_domain_requests import DomainRequestFixture, DomainFixture +from auditlog.context import disable_auditlog +from registrar.fixtures.fixtures_domains import DomainFixture +from registrar.fixtures.fixtures_portfolios import PortfolioFixture +from registrar.fixtures.fixtures_requests import DomainRequestFixture +from registrar.fixtures.fixtures_suborganizations import SuborganizationFixture +from registrar.fixtures.fixtures_user_portfolio_permissions import UserPortfolioPermissionFixture +from registrar.fixtures.fixtures_users import UserFixture # type: ignore logger = logging.getLogger(__name__) @@ -16,6 +18,9 @@ class Command(BaseCommand): # https://github.com/jazzband/django-auditlog/issues/17 with disable_auditlog(): UserFixture.load() + PortfolioFixture.load() + SuborganizationFixture.load() DomainRequestFixture.load() DomainFixture.load() + UserPortfolioPermissionFixture.load() logger.info("All fixtures loaded.") diff --git a/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py b/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py index 506405b78..50b481e7f 100644 --- a/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py +++ b/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py @@ -36,13 +36,13 @@ class Command(BaseCommand, PopulateScriptTemplate): self.federal_agency_dict[agency_name.strip()] = (initials, agency_status) # Update every federal agency record - self.mass_update_records(FederalAgency, {"agency__isnull": False}, ["initials", "is_fceb"]) + self.mass_update_records(FederalAgency, {"agency__isnull": False}, ["acronym", "is_fceb"]) def update_record(self, record: FederalAgency): """For each record, update the initials and is_fceb field if data exists for it""" initials, agency_status = self.federal_agency_dict.get(record.agency) - record.initials = initials + record.acronym = initials if agency_status and isinstance(agency_status, str) and agency_status.strip().upper() == "FCEB": record.is_fceb = True else: diff --git a/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py b/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py new file mode 100644 index 000000000..dae994f8e --- /dev/null +++ b/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.10 on 2024-09-25 00:49 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0128_alter_domaininformation_state_territory_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="portfolioinvitation", + name="portfolio_roles", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("organization_admin", "Admin"), ("organization_member", "Member")], max_length=50 + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="userportfoliopermission", + name="roles", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("organization_admin", "Admin"), ("organization_member", "Member")], max_length=50 + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + ] diff --git a/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py b/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py new file mode 100644 index 000000000..c0e2a0b22 --- /dev/null +++ b/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py @@ -0,0 +1,146 @@ +# Generated by Django 4.2.10 on 2024-09-30 17:59 + +from django.db import migrations, models +import django.db.models.deletion +import registrar.models.federal_agency + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0129_alter_portfolioinvitation_portfolio_roles_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="federalagency", + old_name="initials", + new_name="acronym", + ), + migrations.AlterField( + model_name="federalagency", + name="acronym", + field=models.CharField( + blank=True, + help_text="Acronym commonly used to reference the federal agency (Optional)", + max_length=10, + null=True, + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="portfolio", + field=models.ForeignKey( + blank=True, + help_text="If blank, domain is not associated with a portfolio.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="information_portfolio", + to="registrar.portfolio", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="sub_organization", + field=models.ForeignKey( + blank=True, + help_text="If blank, domain is associated with the overarching organization for this portfolio.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="information_sub_organization", + to="registrar.suborganization", + verbose_name="Suborganization", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="portfolio", + field=models.ForeignKey( + blank=True, + help_text="If blank, request is not associated with a portfolio.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="DomainRequest_portfolio", + to="registrar.portfolio", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="sub_organization", + field=models.ForeignKey( + blank=True, + help_text="If blank, request is associated with the overarching organization for this portfolio.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="request_sub_organization", + to="registrar.suborganization", + verbose_name="Suborganization", + ), + ), + migrations.AlterField( + model_name="federalagency", + name="federal_type", + field=models.CharField( + blank=True, + choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")], + max_length=20, + null=True, + ), + ), + migrations.AlterField( + model_name="federalagency", + name="is_fceb", + field=models.BooleanField( + blank=True, help_text="Federal Civilian Executive Branch (FCEB)", null=True, verbose_name="FCEB" + ), + ), + migrations.AlterField( + model_name="portfolio", + name="federal_agency", + field=models.ForeignKey( + default=registrar.models.federal_agency.FederalAgency.get_non_federal_agency, + on_delete=django.db.models.deletion.PROTECT, + to="registrar.federalagency", + ), + ), + migrations.AlterField( + model_name="portfolio", + name="organization_name", + field=models.CharField(blank=True, null=True), + ), + migrations.AlterField( + model_name="portfolio", + name="organization_type", + field=models.CharField( + blank=True, + choices=[ + ("federal", "Federal"), + ("interstate", "Interstate"), + ("state_or_territory", "State or territory"), + ("tribal", "Tribal"), + ("county", "County"), + ("city", "City"), + ("special_district", "Special district"), + ("school_district", "School district"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="portfolio", + name="senior_official", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="portfolios", + to="registrar.seniorofficial", + ), + ), + migrations.AlterField( + model_name="suborganization", + name="name", + field=models.CharField(max_length=1000, unique=True, verbose_name="Suborganization"), + ), + ] diff --git a/src/registrar/migrations/0131_create_groups_v17.py b/src/registrar/migrations/0131_create_groups_v17.py new file mode 100644 index 000000000..04cd5163c --- /dev/null +++ b/src/registrar/migrations/0131_create_groups_v17.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", "0130_remove_federalagency_initials_federalagency_acronym_and_more"), + ] + + operations = [ + migrations.RunPython( + create_groups, + reverse_code=migrations.RunPython.noop, + atomic=True, + ), + ] diff --git a/src/registrar/migrations/0132_alter_domaininformation_portfolio_and_more.py b/src/registrar/migrations/0132_alter_domaininformation_portfolio_and_more.py new file mode 100644 index 000000000..e0d2b35f9 --- /dev/null +++ b/src/registrar/migrations/0132_alter_domaininformation_portfolio_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.10 on 2024-10-02 14:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0131_create_groups_v17"), + ] + + operations = [ + migrations.AlterField( + model_name="domaininformation", + name="portfolio", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="information_portfolio", + to="registrar.portfolio", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="portfolio", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="DomainRequest_portfolio", + to="registrar.portfolio", + ), + ), + ] diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index d04f09c07..5f98197bd 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -63,7 +63,6 @@ class DomainInformation(TimeStampedModel): null=True, blank=True, related_name="information_portfolio", - help_text="Portfolio associated with this domain", ) sub_organization = models.ForeignKey( @@ -72,7 +71,8 @@ class DomainInformation(TimeStampedModel): null=True, blank=True, related_name="information_sub_organization", - help_text="The suborganization that this domain is included under", + help_text="If blank, domain is associated with the overarching organization for this portfolio.", + verbose_name="Suborganization", ) domain_request = models.OneToOneField( diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 2aecb49f7..c975490bb 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -327,7 +327,6 @@ class DomainRequest(TimeStampedModel): null=True, blank=True, related_name="DomainRequest_portfolio", - help_text="Portfolio associated with this domain request", ) sub_organization = models.ForeignKey( @@ -336,7 +335,8 @@ class DomainRequest(TimeStampedModel): null=True, blank=True, related_name="request_sub_organization", - help_text="The suborganization that this domain request is included under", + help_text="If blank, request is associated with the overarching organization for this portfolio.", + verbose_name="Suborganization", ) # This is the domain request user who created this domain request. @@ -583,6 +583,10 @@ class DomainRequest(TimeStampedModel): blank=True, ) + def is_awaiting_review(self) -> bool: + """Checks if the current status is in submitted or in_review""" + return self.status in [self.DomainRequestStatus.SUBMITTED, self.DomainRequestStatus.IN_REVIEW] + def get_first_status_set_date(self, status): """Returns the date when the domain request was first set to the given status.""" log_entry = ( @@ -999,6 +1003,17 @@ class DomainRequest(TimeStampedModel): send_email=send_email, ) + def is_withdrawable(self): + """Helper function that determines if the request can be withdrawn in its current status""" + # This list is equivalent to the source field on withdraw. We need a better way to + # consolidate these two lists - i.e. some sort of method that keeps these two lists in sync. + # django fsm is very picky with what we can define in that field. + return self.status in [ + self.DomainRequestStatus.SUBMITTED, + self.DomainRequestStatus.IN_REVIEW, + self.DomainRequestStatus.ACTION_NEEDED, + ] + @transition( field="status", source=[DomainRequestStatus.SUBMITTED, DomainRequestStatus.IN_REVIEW, DomainRequestStatus.ACTION_NEEDED], diff --git a/src/registrar/models/federal_agency.py b/src/registrar/models/federal_agency.py index 5cc87b38c..aeeebac8c 100644 --- a/src/registrar/models/federal_agency.py +++ b/src/registrar/models/federal_agency.py @@ -22,21 +22,20 @@ class FederalAgency(TimeStampedModel): choices=BranchChoices.choices, null=True, blank=True, - help_text="Federal agency type (executive, judicial, legislative, etc.)", ) - initials = models.CharField( + acronym = models.CharField( max_length=10, null=True, blank=True, - help_text="Agency initials", + help_text="Acronym commonly used to reference the federal agency (Optional)", ) is_fceb = models.BooleanField( null=True, blank=True, verbose_name="FCEB", - help_text="Determines if this agency is FCEB", + help_text="Federal Civilian Executive Branch (FCEB)", ) def __str__(self) -> str: diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 61d4f7a30..8d820e105 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -2,7 +2,6 @@ from django.db import models from registrar.models.domain_request import DomainRequest from registrar.models.federal_agency import FederalAgency -from registrar.utility.constants import BranchChoices from .utility.time_stamped_model import TimeStampedModel @@ -34,7 +33,6 @@ class Portfolio(TimeStampedModel): organization_name = models.CharField( null=True, blank=True, - verbose_name="Portfolio organization", ) organization_type = models.CharField( @@ -42,7 +40,6 @@ class Portfolio(TimeStampedModel): choices=OrganizationChoices.choices, null=True, blank=True, - help_text="Type of organization", ) notes = models.TextField( @@ -53,7 +50,6 @@ class Portfolio(TimeStampedModel): federal_agency = models.ForeignKey( "registrar.FederalAgency", on_delete=models.PROTECT, - help_text="Associated federal agency", unique=False, default=FederalAgency.get_non_federal_agency, ) @@ -64,6 +60,7 @@ class Portfolio(TimeStampedModel): unique=False, null=True, blank=True, + related_name="portfolios", ) address_line1 = models.CharField( @@ -125,23 +122,6 @@ class Portfolio(TimeStampedModel): super().save(*args, **kwargs) - @property - def portfolio_type(self): - """ - Returns a combination of organization_type / federal_type, seperated by ' - '. - If no federal_type is found, we just return the org type. - """ - return self.get_portfolio_type(self.organization_type, self.federal_type) - - @classmethod - def get_portfolio_type(cls, organization_type, federal_type): - org_type_label = cls.OrganizationChoices.get_org_label(organization_type) - agency_type_label = BranchChoices.get_branch_label(federal_type) - if organization_type == cls.OrganizationChoices.FEDERAL and agency_type_label: - return " - ".join([org_type_label, agency_type_label]) - else: - return org_type_label - @property def federal_type(self): """Returns the federal_type value on the underlying federal_agency field""" @@ -152,13 +132,19 @@ class Portfolio(TimeStampedModel): return federal_agency.federal_type if federal_agency else None # == Getters for domains == # - def get_domains(self): + def get_domains(self, order_by=None): """Returns all DomainInformations associated with this portfolio""" - return self.information_portfolio.all() + if not order_by: + return self.information_portfolio.all() + else: + return self.information_portfolio.all().order_by(*order_by) - def get_domain_requests(self): + def get_domain_requests(self, order_by=None): """Returns all DomainRequests associated with this portfolio""" - return self.DomainRequest_portfolio.all() + if not order_by: + return self.DomainRequest_portfolio.all() + else: + return self.DomainRequest_portfolio.all().order_by(*order_by) # == Getters for suborganization == # def get_suborganizations(self): diff --git a/src/registrar/models/suborganization.py b/src/registrar/models/suborganization.py index feeee0669..6ad80fdc0 100644 --- a/src/registrar/models/suborganization.py +++ b/src/registrar/models/suborganization.py @@ -10,7 +10,7 @@ class Suborganization(TimeStampedModel): name = models.CharField( unique=True, max_length=1000, - help_text="Suborganization", + verbose_name="Suborganization", ) portfolio = models.ForeignKey( diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 929a63525..ae76d648b 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -3,7 +3,6 @@ import logging from django.contrib.auth.models import AbstractUser from django.db import models from django.db.models import Q -from django.http import HttpRequest from registrar.models import DomainInformation, UserDomainRole from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices @@ -14,6 +13,7 @@ from .transition_domain import TransitionDomain from .verified_by_staff import VerifiedByStaff from .domain import Domain from .domain_request import DomainRequest +from registrar.utility.waffle import flag_is_active_for_user from waffle.decorators import flag_is_active from phonenumber_field.modelfields import PhoneNumberField # type: ignore @@ -204,14 +204,10 @@ class User(AbstractUser): ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) def has_organization_requests_flag(self): - request = HttpRequest() - request.user = self - return flag_is_active(request, "organization_requests") + return flag_is_active_for_user(self, "organization_requests") def has_organization_members_flag(self): - request = HttpRequest() - request.user = self - return flag_is_active(request, "organization_members") + return flag_is_active_for_user(self, "organization_members") def has_view_members_portfolio_permission(self, portfolio): # BEGIN @@ -422,12 +418,8 @@ class User(AbstractUser): for invitation in PortfolioInvitation.objects.filter( email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED ): - # need to create a bogus request and assign user to it, in order to pass request - # to flag_is_active - request = HttpRequest() - request.user = self only_single_portfolio = ( - not flag_is_active(request, "multiple_portfolios") and self.get_first_portfolio() is None + not flag_is_active_for_user(self, "multiple_portfolios") and self.get_first_portfolio() is None ) if only_single_portfolio or flag_is_active(None, "multiple_portfolios"): try: diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index 76657fe29..4770f34bc 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -66,6 +66,30 @@ class UserGroup(Group): "model": "federalagency", "permissions": ["add_federalagency", "change_federalagency", "delete_federalagency"], }, + { + "app_label": "registrar", + "model": "portfolio", + "permissions": ["add_portfolio", "change_portfolio", "delete_portfolio"], + }, + { + "app_label": "registrar", + "model": "suborganization", + "permissions": ["add_suborganization", "change_suborganization", "delete_suborganization"], + }, + { + "app_label": "registrar", + "model": "seniorofficial", + "permissions": ["add_seniorofficial", "change_seniorofficial", "delete_seniorofficial"], + }, + { + "app_label": "registrar", + "model": "userportfoliopermission", + "permissions": [ + "add_userportfoliopermission", + "change_userportfoliopermission", + "delete_userportfoliopermission", + ], + }, ] # Avoid error: You can't execute queries until the end @@ -113,7 +137,6 @@ class UserGroup(Group): + cisa_analysts_group.name ) - cisa_analysts_group.save() logger.debug("CISA Analyst permissions added to group " + cisa_analysts_group.name) except Exception as e: logger.error(f"Error creating analyst permissions group: {e}") @@ -135,7 +158,6 @@ class UserGroup(Group): # Assign all permissions to the group full_access_group.permissions.add(*all_permissions) - full_access_group.save() logger.debug("All permissions added to group " + full_access_group.name) except Exception as e: logger.error(f"Error creating full access group: {e}") diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 0c2487df3..241afd328 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -1,7 +1,6 @@ from django.db import models from django.forms import ValidationError -from django.http import HttpRequest -from waffle import flag_is_active +from registrar.utility.waffle import flag_is_active_for_user from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .utility.time_stamped_model import TimeStampedModel from django.contrib.postgres.fields import ArrayField @@ -16,8 +15,6 @@ class UserPortfolioPermission(TimeStampedModel): PORTFOLIO_ROLE_PERMISSIONS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - UserPortfolioPermissionChoices.VIEW_MEMBERS, - UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_PORTFOLIO, @@ -26,14 +23,6 @@ class UserPortfolioPermission(TimeStampedModel): UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, ], - UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [ - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - UserPortfolioPermissionChoices.VIEW_MEMBERS, - UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, - UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - # Domain: field specific permissions - UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, - ], UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ UserPortfolioPermissionChoices.VIEW_PORTFOLIO, ], @@ -76,7 +65,19 @@ class UserPortfolioPermission(TimeStampedModel): ) def __str__(self): - return f"User '{self.user}' on Portfolio '{self.portfolio}' " f"" if self.roles else "" + readable_roles = [] + if self.roles: + readable_roles = self.get_readable_roles() + return f"{self.user}" f" " if self.roles else "" + + def get_readable_roles(self): + """Returns a readable list of self.roles""" + readable_roles = [] + if self.roles: + readable_roles = sorted( + [UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles] + ) + return readable_roles def _get_portfolio_permissions(self): """ @@ -101,13 +102,11 @@ class UserPortfolioPermission(TimeStampedModel): # Check if a user is set without accessing the related object. has_user = bool(self.user_id) if self.pk is None and has_user: - # Have to create a bogus request to set the user and pass to flag_is_active - request = HttpRequest() - request.user = self.user existing_permissions = UserPortfolioPermission.objects.filter(user=self.user) - if not flag_is_active(request, "multiple_portfolios") and existing_permissions.exists(): + if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permissions.exists(): raise ValidationError( - "Only one portfolio permission is allowed per user when multiple portfolios are disabled." + "This user is already assigned to a portfolio. " + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." ) # Check if portfolio is set without accessing the related object. diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index 3cafe87c4..5e425f5a3 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -334,3 +334,12 @@ def get_url_name(path): except Resolver404: logger.error(f"No matching URL name found for path: {path}") return None + + +def value_of_attribute(obj, attribute_name: str): + """Returns the value of getattr if the attribute isn't callable. + If it is, execute the underlying function and return that result instead.""" + value = getattr(obj, attribute_name) + if callable(value): + value = value() + return value diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 7f34221fd..ddb487f71 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -7,9 +7,12 @@ class UserPortfolioRoleChoices(models.TextChoices): """ ORGANIZATION_ADMIN = "organization_admin", "Admin" - ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only" ORGANIZATION_MEMBER = "organization_member", "Member" + @classmethod + def get_user_portfolio_role_label(cls, user_portfolio_role): + return cls(user_portfolio_role).label if user_portfolio_role else None + class UserPortfolioPermissionChoices(models.TextChoices): """ """ @@ -29,3 +32,7 @@ class UserPortfolioPermissionChoices(models.TextChoices): # Domain: field specific permissions VIEW_SUBORGANIZATION = "view_suborganization", "View suborganization" EDIT_SUBORGANIZATION = "edit_suborganization", "Edit suborganization" + + @classmethod + def get_user_portfolio_permission_label(cls, user_portfolio_permission): + return cls(user_portfolio_permission).label if user_portfolio_permission else None diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 5a75577df..2ccea9321 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -49,11 +49,15 @@ class CheckUserProfileMiddleware: self.setup_page, self.logout_page, "/admin", + # These are here as there is a bug with this middleware that breaks djangos built in debug console. + # The debug console uses this directory, but since this overrides that, it throws errors. + "/__debug__", ] self.other_excluded_pages = [ self.profile_page, self.logout_page, "/admin", + "/__debug__", ] self.excluded_pages = { diff --git a/src/registrar/templates/401.html b/src/registrar/templates/401.html index dd1801cc5..20ca0420e 100644 --- a/src/registrar/templates/401.html +++ b/src/registrar/templates/401.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Unauthorized | " %}{% endblock %} {% block content %} -
    +

    diff --git a/src/registrar/templates/403.html b/src/registrar/templates/403.html index 660e5d34c..ef910a191 100644 --- a/src/registrar/templates/403.html +++ b/src/registrar/templates/403.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Forbidden | " %}{% endblock %} {% block content %} -
    +

    diff --git a/src/registrar/templates/404.html b/src/registrar/templates/404.html index cf6983c60..024c2803b 100644 --- a/src/registrar/templates/404.html +++ b/src/registrar/templates/404.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Page not found | " %}{% endblock %} {% block content %} -
    +

    diff --git a/src/registrar/templates/500.html b/src/registrar/templates/500.html index dfbd90142..95c17e069 100644 --- a/src/registrar/templates/500.html +++ b/src/registrar/templates/500.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Server error | " %}{% endblock %} {% block content %} -
    +

    diff --git a/src/registrar/templates/base.html b/src/registrar/templates/base.html index e5bd1b0b9..b14dab2fa 100644 --- a/src/registrar/templates/base.html +++ b/src/registrar/templates/base.html @@ -78,7 +78,7 @@
    -
    +
    U.S. flag
    diff --git a/src/registrar/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html index 0396326d9..afdd9e6c2 100644 --- a/src/registrar/templates/django/admin/domain_request_change_form.html +++ b/src/registrar/templates/django/admin/domain_request_change_form.html @@ -8,6 +8,8 @@ {# Store the current object id so we can access it easier #} + {% url 'get-action-needed-email-for-user-json' as url %} + {% for fieldset in adminform %} {% comment %} TODO: this will eventually need to be changed to something like this 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 7cc72e8e1..0a28a6532 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -39,7 +39,7 @@ None
    {% endif %} - {% else %} + {% elif not hide_no_contact_info_message %} No additional contact information found.
    {% endif %} diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index e22bcb571..6b755724e 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -66,24 +66,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) No changelog to display.
    {% endif %} - {% elif field.field.name == "action_needed_reason_email" %} -
    - - -
    {% elif field.field.name == "other_contacts" %} {% if all_contacts.count > 2 %}
    @@ -137,7 +119,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endfor %} {% endwith %}
    - {% elif field.field.name == "display_admins" %} + {% elif field.field.name == "domain_managers" or field.field.name == "invited_domain_managers" %}
    {{ field.contents|safe }}
    {% elif field.field.name == "display_members" %}
    @@ -155,131 +137,102 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% block field_other %} {% if field.field.name == "action_needed_reason_email" %} -
    -
    - - + +
    + –
    -
    -
    - {% endfor %} + {% include "includes/request_review_steps.html" with is_editable=True %} {% endblock %} diff --git a/src/registrar/templates/domain_request_status.html b/src/registrar/templates/domain_request_status.html index 460f6ae29..d332ce54e 100644 --- a/src/registrar/templates/domain_request_status.html +++ b/src/registrar/templates/domain_request_status.html @@ -1,206 +1,10 @@ {% extends 'base.html' %} - {% load custom_filters %} - -{% block title %}Domain request status | {{ DomainRequest.requested_domain.name }} | {% endblock %} {% load static url_helpers %} +{% block title %}Domain request status | {{ DomainRequest.requested_domain.name }} | {% endblock %} + + {% block content %} -
    -
    - {% if portfolio %} - {% url 'domain-requests' as url %} - {% else %} - {% url 'home' as url %} - {% endif %} - - -

    Domain request for {{ DomainRequest.requested_domain.name }}

    -
    -
    -

    - - Status: - - {{ DomainRequest.get_status_display|default:"ERROR Please contact technical support/dev" }} -

    -
    -
    -
    - - {% with statuses=DomainRequest.DomainRequestStatus last_submitted=DomainRequest.last_submitted_date|date:"F j, Y" first_submitted=DomainRequest.first_submitted_date|date:"F j, Y" last_status_update=DomainRequest.last_status_update|date:"F j, Y" %} - {% comment %} - These are intentionally seperated this way. - There is some code repetition, but it gives us more flexibility rather than a dense reduction. - Leave it this way until we've solidified our requirements. - {% endcomment %} - {% if DomainRequest.status == statuses.STARTED %} - {% with first_started_date=DomainRequest.get_first_status_started_date|date:"F j, Y" %} -

    - {% comment %} - A newly created domain request will not have a value for last_status update. - This is because the status never really updated. - However, if this somehow goes back to started we can default to displaying that new date. - {% endcomment %} - Started on: {{last_status_update|default:first_started_date}} -

    - {% endwith %} - {% elif DomainRequest.status == statuses.SUBMITTED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% elif DomainRequest.status == statuses.ACTION_NEEDED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% elif DomainRequest.status == statuses.REJECTED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Rejected on: {{last_status_update}} -

    - {% elif DomainRequest.status == statuses.WITHDRAWN %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Withdrawn on: {{last_status_update}} -

    - {% else %} - {% comment %} Shown for in_review, approved, ineligible {% endcomment %} -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% endif %} - - {% if DomainRequest.status != 'rejected' %} -

    {% include "includes/domain_request.html" %}

    -

    - Withdraw request -

    - {% endif %} - {% endwith %} -
    - -
    -

    Summary of your domain request

    - {% with heading_level='h3' %} - {% with org_type=DomainRequest.get_generic_org_type_display %} - {% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %} - {% endwith %} - - {% if DomainRequest.tribe_name %} - {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %} - - {% if DomainRequest.federally_recognized_tribe %} -

    Federally-recognized tribe

    - {% endif %} - - {% if DomainRequest.state_recognized_tribe %} -

    State-recognized tribe

    - {% endif %} - - {% endif %} - - {% if DomainRequest.get_federal_type_display %} - {% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.is_election_board %} - {% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %} - {% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %} - {% endwith %} - {% endif %} - - {% if DomainRequest.organization_name %} - {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.about_your_organization %} - {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.senior_official %} - {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.current_websites.all %} - {% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.requested_domain %} - {% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.alternative_domains.all %} - {% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.purpose %} - {% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.creator %} - {% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %} - {% endif %} - {% if DomainRequest.other_contacts.all %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %} - {% else %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} - {% endif %} - - {# We always show this field even if None #} - {% if DomainRequest %} -

    CISA Regional Representative

    -
      - {% if DomainRequest.cisa_representative_first_name %} - {{ DomainRequest.get_formatted_cisa_rep_name }} - {% else %} - No - {% endif %} -
    - -

    Anything else

    -
      - {% if DomainRequest.anything_else %} - {{DomainRequest.anything_else}} - {% else %} - No - {% endif %} -
    - {% endif %} - {% endwith %} -
    - -
    + {% include "includes/request_status_manage.html" %} {% endblock %} diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index 63924bc1d..65c52ec9e 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -5,7 +5,7 @@ {% block title %} Home | {% endblock %} {% block content %} -
    +
    {% if user.is_authenticated %} {# the entire logged in page goes here #} diff --git a/src/registrar/templates/includes/domain_request.html b/src/registrar/templates/includes/domain_request_awaiting_review.html similarity index 57% rename from src/registrar/templates/includes/domain_request.html rename to src/registrar/templates/includes/domain_request_awaiting_review.html index 0e377f35c..cc9b31ccb 100644 --- a/src/registrar/templates/includes/domain_request.html +++ b/src/registrar/templates/includes/domain_request_awaiting_review.html @@ -5,8 +5,10 @@

    We received your .gov domain request. Our next step is to review your request. This usually takes 30 business days. We’ll email you if we have questions and when we complete our review. Contact us with any questions.

    -

    - Need to make changes? -

    +{% if show_withdraw_text %} +

    + Need to make changes? +

    -

    If you need to change your request you have to first withdraw it. Once you withdraw the request you can edit it and submit it again. Changing your request might add to the wait time.

    +

    If you need to change your request you have to first withdraw it. Once you withdraw the request you can edit it and submit it again. Changing your request might add to the wait time.

    +{% endif %} diff --git a/src/registrar/templates/includes/domain_request_status_manage.html b/src/registrar/templates/includes/domain_request_status_manage.html new file mode 100644 index 000000000..2a254df4b --- /dev/null +++ b/src/registrar/templates/includes/domain_request_status_manage.html @@ -0,0 +1,236 @@ +{% load custom_filters %} +{% load static url_helpers %} +
    +
    + {% block breadcrumb %} + {% if portfolio %} + {% url 'domain-requests' as url %} + {% else %} + {% url 'home' as url %} + {% endif %} + + {% endblock breadcrumb %} + + {% block header %} + {% if not DomainRequest.requested_domain and DomainRequest.status == DomainRequest.DomainRequestStatus.STARTED %} +

    New domain request

    + {% else %} +

    Domain request for {{ DomainRequest.requested_domain.name }}

    + {% endif %} + {% endblock header %} + + {% block status_summary %} +
    +
    +

    + + Status: + + {{ DomainRequest.get_status_display|default:"ERROR Please contact technical support/dev" }} +

    +
    +
    +
    + {% endblock status_summary %} + + {% block status_metadata %} + + {% if portfolio %} + {% if DomainRequest.creator %} +

    + Created by: {{DomainRequest.creator.email|default:DomainRequest.creator.get_formatted_name }} +

    + {% else %} +

    + No creator found: this is an error, please email help@get.gov. +

    + {% endif %} + {% endif %} + + {% with statuses=DomainRequest.DomainRequestStatus last_submitted=DomainRequest.last_submitted_date|date:"F j, Y" first_submitted=DomainRequest.first_submitted_date|date:"F j, Y" last_status_update=DomainRequest.last_status_update|date:"F j, Y" %} + {% comment %} + These are intentionally seperated this way. + There is some code repetition, but it gives us more flexibility rather than a dense reduction. + Leave it this way until we've solidified our requirements. + {% endcomment %} + {% if DomainRequest.status == statuses.STARTED %} + {% with first_started_date=DomainRequest.get_first_status_started_date|date:"F j, Y" %} +

    + {% comment %} + A newly created domain request will not have a value for last_status update. + This is because the status never really updated. + However, if this somehow goes back to started we can default to displaying that new date. + {% endcomment %} + Started on: {{last_status_update|default:first_started_date}} +

    + {% endwith %} + {% elif DomainRequest.status == statuses.SUBMITTED %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} +

    + {% elif DomainRequest.status == statuses.ACTION_NEEDED %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} +

    + {% elif DomainRequest.status == statuses.REJECTED %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Rejected on: {{last_status_update}} +

    + {% elif DomainRequest.status == statuses.WITHDRAWN %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Withdrawn on: {{last_status_update}} +

    + {% else %} + {% comment %} Shown for in_review, approved, ineligible {% endcomment %} +

    + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} +

    + {% endif %} + {% endwith %} + {% endblock status_metadata %} + + {% block status_blurb %} + {% if DomainRequest.is_awaiting_review %} +

    {% include "includes/domain_request_awaiting_review.html" with show_withdraw_text=DomainRequest.is_withdrawable %}

    + {% endif %} + {% endblock status_blurb %} + + {% block modify_request %} + {% if DomainRequest.is_withdrawable %} +

    + Withdraw request +

    + {% endif %} + {% endblock modify_request %} +
    + +
    + {% block request_summary_header %} +

    Summary of your domain request

    + {% endblock request_summary_header%} + + {% block request_summary %} + {% with heading_level='h3' %} + {% with org_type=DomainRequest.get_generic_org_type_display %} + {% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %} + {% endwith %} + + {% if DomainRequest.tribe_name %} + {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %} + + {% if DomainRequest.federally_recognized_tribe %} +

    Federally-recognized tribe

    + {% endif %} + + {% if DomainRequest.state_recognized_tribe %} +

    State-recognized tribe

    + {% endif %} + + {% endif %} + + {% if DomainRequest.get_federal_type_display %} + {% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.is_election_board %} + {% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %} + {% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %} + {% endwith %} + {% endif %} + + {% if DomainRequest.organization_name %} + {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.about_your_organization %} + {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.senior_official %} + {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.current_websites.all %} + {% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.requested_domain %} + {% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.alternative_domains.all %} + {% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.purpose %} + {% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.creator %} + {% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.other_contacts.all %} + {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %} + {% else %} + {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} + {% endif %} + + {# We always show this field even if None #} + {% if DomainRequest %} +

    CISA Regional Representative

    +
      + {% if DomainRequest.cisa_representative_first_name %} + {{ DomainRequest.get_formatted_cisa_rep_name }} + {% else %} + No + {% endif %} +
    +

    Anything else

    +
      + {% if DomainRequest.anything_else %} + {{DomainRequest.anything_else}} + {% else %} + No + {% endif %} +
    + {% endif %} + {% endwith %} + {% endblock request_summary%} +
    +
    \ No newline at end of file diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 76ead3a2c..11d3ac945 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -13,7 +13,7 @@ {% endif %} -