diff --git a/.github/ISSUE_TEMPLATE/issue-default.yml b/.github/ISSUE_TEMPLATE/issue-default.yml index 254078246..292d0ebec 100644 --- a/.github/ISSUE_TEMPLATE/issue-default.yml +++ b/.github/ISSUE_TEMPLATE/issue-default.yml @@ -1,18 +1,18 @@ -name: Issue -description: Describe an idea, feature, content, or non-bug finding +name: Issue / story +description: Describe an idea, problem, feature, or story. (Report bugs in the Bug template.) body: - type: markdown id: title-help attributes: value: | - > Titles should be short, descriptive, and compelling. Use sentence case. + > Titles should be short, descriptive, and compelling. Use sentence case: don't capitalize words unnecessarily. - type: textarea id: issue-description attributes: label: Issue description description: | - Describe the issue so that someone who wasn't present for its discovery can understand why it matters. Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax). + Describe the issue so that someone who wasn't present for its discovery can understand why it matters. For stories, use the user story format (e.g., As a user, I want, so that). Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax). validations: required: true - type: textarea @@ -31,7 +31,7 @@ body: attributes: label: Links to other issues description: | - "With a `-` to start the line, add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to)." + "Use a dash (`-`) to start the line. Add an issue by typing "`#`" then the issue number. Add information to describe any dependancies, blockers, etc. (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to). If this is a parent issue, use sub-issues instead of linking other issues here." placeholder: "- 🔄 Relates to..." - type: markdown id: note diff --git a/.github/ISSUE_TEMPLATE/sub-issue.yml b/.github/ISSUE_TEMPLATE/sub-issue.yml new file mode 100644 index 000000000..e0b98e3d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/sub-issue.yml @@ -0,0 +1,36 @@ +name: Sub-issue +description: Describe an idea, problem, or feature that is related to a parent issue. + +body: + - type: markdown + id: title-help + attributes: + value: | + > Titles should be short, descriptive, and compelling. Use sentence case (don't capitalize unnecessarily). + - type: textarea + id: description + attributes: + label: Sub-issue description + description: | + Describe the issue so that someone who wasn't present for its discovery can understand why it matters. Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax). + + For stories, use the user story format (e.g., As a user, I want, So that). + validations: + required: true + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance criteria + description: If known, share 1-3 statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate. + placeholder: "- [ ]" + - type: textarea + id: additional-context + attributes: + label: Additional context + description: "Share any other thoughts, like how this might be implemented or fixed. Screenshots and links to documents/discussions are welcome." + - type: markdown + id: note + attributes: + value: | + > We may edit the text in this issue to document our understanding and clarify the product work. + diff --git a/docs/developer/README.md b/docs/developer/README.md index 0fa9d9a8c..46194bd70 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -378,3 +378,18 @@ Then, copy the variables under the section labled `s3`. ## Request Flow FSM Diagram The [.gov Domain Request & Domain Status Digram](https://miro.com/app/board/uXjVMuqbLOk=/?moveToWidget=3458764594819017396&cot=14) visualizes the domain request flow and resulting domain objects. + + +## Testing the prototype add DNS record feature (delete this after we are done testing!) +We are currently testing using cloudflare to add DNS records. Specifically, an A record. To use this, you will need to enable the +`prototype_dns_flag` waffle flag and navigate to `igorville.gov`, `dns.gov`, or `domainops.gov`. Click manage, then click DNS. From there, click the `Prototype DNS record creator` button. + +Before we can send data to cloudflare, you will need these values in your .env file: +``` +REGISTRY_TENANT_KEY = {tenant key} +REGISTRY_SERVICE_EMAIL = {An email address} +REGISTRY_TENANT_NAME = {Name of the bucket, i.e. "CISA" } +``` +You can obtain these by following the steps outlined in the [dns hosting discovery doc](https://docs.google.com/document/d/1Yq5d2M3MgM2vPhUBZ0k5wOmCQst4vND9-2qEZ55-h-Y/edit?tab=t.0), BUT it is far easier to just get these from someone else. Reach out to Zander for this information if you do not have it. + +Alternatively, if you are testing on a sandbox, you will need to add those to getgov-credentials. diff --git a/docs/developer/adding-feature-flags.md b/docs/developer/adding-feature-flags.md index dc51b9e85..c4e6eda89 100644 --- a/docs/developer/adding-feature-flags.md +++ b/docs/developer/adding-feature-flags.md @@ -16,6 +16,14 @@ We use [django-waffle](https://waffle.readthedocs.io/en/stable/) for our feature 4. (Important) Set the field `Everyone` to `Unknown`. This field overrides all other settings when set to anything else. 5. Configure the settings as you see fit. +## Enabling a feature flag with portfolio permissions +1. Go to file `context_processors.py` +2. Add feature flag name to the `porfolio_context` within the `portfolio_permissions` method. +3. For the conditional under `if portfolio`, add the feature flag name, and assign the appropiate permission that are in the `user.py` model. + +#### Note: +- If your use case includes non org, you want to add a feature flag outside of it, you can just update the portfolio context outside of the if statement. + ## Using feature flags as boolean values Waffle [provides a boolean](https://waffle.readthedocs.io/en/stable/usage/views.html) called `flag_is_active` that you can use as you otherwise would a boolean. This boolean requires a request object and the flag name. diff --git a/docs/operations/runbooks/add_secrets_to_existing_sandbox.md b/docs/operations/runbooks/add_secrets_to_existing_sandbox.md new file mode 100644 index 000000000..411f9c90a --- /dev/null +++ b/docs/operations/runbooks/add_secrets_to_existing_sandbox.md @@ -0,0 +1,73 @@ +# HOWTO Add secrets to an existing sandbox + + +### Check if you need to add secrets +Run this command to get the environment variables from a sandbox: + +```sh +cf env +``` +For example `cf env getgov-development` + +Check that these environment variables exist: +``` +{ + "DJANGO_SECRET_KEY": "EXAMPLE", + "DJANGO_SECRET_LOGIN_KEY": "EXAMPLE", + "AWS_ACCESS_KEY_ID": "EXAMPLE", + "AWS_SECRET_ACCESS_KEY": "EXAMPLE", + "REGISTRY_KEY": "EXAMPLE, + ... +} +``` + +If those variable are not present, use the following steps to set secrets by creating a new `credentials-.json` file and uploading it. +(Note that many of these commands were taken from the [`create_dev_sandbox.sh`](../../../ops/scripts/create_dev_sandbox.sh) script and were tested on MacOS) + +### Create a new Django key +```sh +django_key=$(python3 -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())') +``` + +### Replace the existing certificate +Create a certificate: +```sh +openssl req -nodes -x509 -days 365 -newkey rsa:2048 -keyout private-.pem -out public-.crt +``` + +Fill in the following for the prompts: + +Note: for "Common Name" you should put the name of the sandbox and for "Email Address" it should be the address of who owns that sandbox (such as the developer's email, if it's a developer sandbox, or whoever ran this action otherwise) + +```sh +Country Name (2 letter code) [AU]: US +State or Province Name (full name) [Some-State]: DC +Locality Name (eg, city) []: DC +Organization Name (eg, company) [Internet Widgits Pty Ltd]: DHS +Organizational Unit Name (eg, section) []: CISA +Common Name (e.g. server FQDN or YOUR name) []: +Email Address []: +``` +Go to https://dashboard.int.identitysandbox.gov/service_providers/2640/edit to remove the old certificate and upload the new one. + +### Create the login key +```sh +login_key=$(base64 -i private-.pem) +``` + +### Create the credentials file +```sh +jq -n --arg django_key "$django_key" --arg login_key "$login_key" '{"DJANGO_SECRET_KEY":$django_key,"DJANGO_SECRET_LOGIN_KEY":$login_key}' > credentials-.json +``` + +Copy `REGISTRY_*` credentials from another sandbox into your `credentials-.json` file. Also add your `AWS_*` credentials if you have them, otherwise also copy them from another sandbox. You can either use the cloud.gov dashboard or the command `cf env ` to find other credentials. + +### Update the `getgov-credentials` service tied to your environment. +```sh +cf uups getgov-credentials -p credentials-.json +``` + +### Restage your application +```sh +cf restage getgov- --strategy rolling +``` \ No newline at end of file diff --git a/ops/scripts/create_dev_sandbox.sh b/ops/scripts/create_dev_sandbox.sh index 6cbad9c4f..1796817a8 100755 --- a/ops/scripts/create_dev_sandbox.sh +++ b/ops/scripts/create_dev_sandbox.sh @@ -136,6 +136,7 @@ then fi cf service-key github-cd-account github-cd-key | sed 1,2d | jq -r '[.username, .password]|@tsv' | + while read -r username password; do gh secret --repo cisagov/getgov set CF_${upcase_name}_USERNAME --body $username gh secret --repo cisagov/getgov set CF_${upcase_name}_PASSWORD --body $password diff --git a/src/docker-compose.yml b/src/docker-compose.yml index f20aa61e4..5ad6d0ce6 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -59,6 +59,9 @@ services: - AWS_S3_BUCKET_NAME # File encryption credentials - SECRET_ENCRYPT_METADATA + - REGISTRY_TENANT_KEY + - REGISTRY_SERVICE_EMAIL + - REGISTRY_TENANT_NAME stdin_open: true tty: true ports: diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 5a9118549..fecd17146 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3,7 +3,14 @@ import logging import copy from typing import Optional from django import forms -from django.db.models import Value, CharField, Q +from django.db.models import ( + Case, + CharField, + F, + Q, + Value, + When, +) from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect from registrar.models.federal_agency import FederalAgency @@ -1467,21 +1474,57 @@ class DomainInformationResource(resources.ModelResource): class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): """Customize domain information admin class.""" + class GenericOrgFilter(admin.SimpleListFilter): + """Custom Generic Organization filter that accomodates portfolio feature. + If we have a portfolio, use the portfolio's organization. If not, use the + organization in the Domain Information object.""" + + title = "generic organization" + parameter_name = "converted_generic_orgs" + + def lookups(self, request, model_admin): + converted_generic_orgs = set() + + # Populate the set with tuples of (value, display value) + for domain_info in DomainInformation.objects.all(): + converted_generic_org = domain_info.converted_generic_org_type # Actual value + converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value + + if converted_generic_org: + converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display + + # Sort the set by display value + return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value + + # Filter queryset + def queryset(self, request, queryset): + if self.value(): # Check if a generic org is selected in the filter + return queryset.filter( + Q(portfolio__organization_type=self.value()) + | Q(portfolio__isnull=True, generic_org_type=self.value()) + ) + return queryset + resource_classes = [DomainInformationResource] form = DomainInformationAdminForm + # Customize column header text + @admin.display(description=_("Generic Org Type")) + def converted_generic_org_type(self, obj): + return obj.converted_generic_org_type_display + # Columns list_display = [ "domain", - "generic_org_type", + "converted_generic_org_type", "created_at", ] orderable_fk_fields = [("domain", "name")] # Filters - list_filter = ["generic_org_type"] + list_filter = [GenericOrgFilter] # Search search_fields = [ @@ -1661,24 +1704,23 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def lookups(self, request, model_admin): converted_generic_orgs = set() + # Populate the set with tuples of (value, display value) for domain_request in DomainRequest.objects.all(): - converted_generic_org = domain_request.converted_generic_org_type - if converted_generic_org: - converted_generic_orgs.add(converted_generic_org) + converted_generic_org = domain_request.converted_generic_org_type # Actual value + converted_generic_org_display = domain_request.converted_generic_org_type_display # Display value - return sorted((org, org) for org in converted_generic_orgs) + if converted_generic_org: + converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display + + # Sort the set by display value + return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value # Filter queryset def queryset(self, request, queryset): if self.value(): # Check if a generic org is selected in the filter return queryset.filter( - # Filter based on the generic org value returned by converted_generic_org_type - id__in=[ - domain_request.id - for domain_request in queryset - if domain_request.converted_generic_org_type - and domain_request.converted_generic_org_type == self.value() - ] + Q(portfolio__organization_type=self.value()) + | Q(portfolio__isnull=True, generic_org_type=self.value()) ) return queryset @@ -1693,24 +1735,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def lookups(self, request, model_admin): converted_federal_types = set() + # Populate the set with tuples of (value, display value) for domain_request in DomainRequest.objects.all(): - converted_federal_type = domain_request.converted_federal_type - if converted_federal_type: - converted_federal_types.add(converted_federal_type) + converted_federal_type = domain_request.converted_federal_type # Actual value + converted_federal_type_display = domain_request.converted_federal_type_display # Display value - return sorted((type, type) for type in converted_federal_types) + if converted_federal_type: + converted_federal_types.add( + (converted_federal_type, converted_federal_type_display) # Value, Display + ) + + # Sort the set by display value + return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if federal Type is selected in the filter + if self.value(): # Check if a federal type is selected in the filter return queryset.filter( - # Filter based on the federal type returned by converted_federal_type - id__in=[ - domain_request.id - for domain_request in queryset - if domain_request.converted_federal_type - and domain_request.converted_federal_type == self.value() - ] + Q(portfolio__federal_agency__federal_type=self.value()) + | Q(portfolio__isnull=True, federal_type=self.value()) ) return queryset @@ -1776,7 +1819,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): @admin.display(description=_("Generic Org Type")) def converted_generic_org_type(self, obj): - return obj.converted_generic_org_type + return obj.converted_generic_org_type_display @admin.display(description=_("Organization Name")) def converted_organization_name(self, obj): @@ -1788,7 +1831,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): @admin.display(description=_("Federal Type")) def converted_federal_type(self, obj): - return obj.converted_federal_type + return obj.converted_federal_type_display @admin.display(description=_("City")) def converted_city(self, obj): @@ -2679,6 +2722,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): resource_classes = [DomainResource] + # ------- FILTERS class ElectionOfficeFilter(admin.SimpleListFilter): """Define a custom filter for is_election_board""" @@ -2697,18 +2741,135 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): if self.value() == "0": return queryset.filter(Q(domain_info__is_election_board=False) | Q(domain_info__is_election_board=None)) + class GenericOrgFilter(admin.SimpleListFilter): + """Custom Generic Organization filter that accomodates portfolio feature. + If we have a portfolio, use the portfolio's organization. If not, use the + organization in the Domain Information object.""" + + title = "generic organization" + parameter_name = "converted_generic_orgs" + + def lookups(self, request, model_admin): + converted_generic_orgs = set() + + # Populate the set with tuples of (value, display value) + for domain_info in DomainInformation.objects.all(): + converted_generic_org = domain_info.converted_generic_org_type # Actual value + converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value + + if converted_generic_org: + converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display + + # Sort the set by display value + return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value + + # Filter queryset + def queryset(self, request, queryset): + if self.value(): # Check if a generic org is selected in the filter + return queryset.filter( + Q(domain_info__portfolio__organization_type=self.value()) + | Q(domain_info__portfolio__isnull=True, domain_info__generic_org_type=self.value()) + ) + + return queryset + + class FederalTypeFilter(admin.SimpleListFilter): + """Custom Federal Type filter that accomodates portfolio feature. + If we have a portfolio, use the portfolio's federal type. If not, use the + federal type in the Domain Information object.""" + + title = "federal type" + parameter_name = "converted_federal_types" + + def lookups(self, request, model_admin): + converted_federal_types = set() + + # Populate the set with tuples of (value, display value) + for domain_info in DomainInformation.objects.all(): + converted_federal_type = domain_info.converted_federal_type # Actual value + converted_federal_type_display = domain_info.converted_federal_type_display # Display value + + if converted_federal_type: + converted_federal_types.add( + (converted_federal_type, converted_federal_type_display) # Value, Display + ) + + # Sort the set by display value + return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value + + # Filter queryset + def queryset(self, request, queryset): + if self.value(): # Check if a federal type is selected in the filter + return queryset.filter( + Q(domain_info__portfolio__federal_agency__federal_type=self.value()) + | Q(domain_info__portfolio__isnull=True, domain_info__federal_agency__federal_type=self.value()) + ) + return queryset + + def get_annotated_queryset(self, queryset): + return queryset.annotate( + converted_generic_org_type=Case( + # When portfolio is present, use its value instead + When(domain_info__portfolio__isnull=False, then=F("domain_info__portfolio__organization_type")), + # Otherwise, return the natively assigned value + default=F("domain_info__generic_org_type"), + ), + converted_federal_agency=Case( + # When portfolio is present, use its value instead + When( + Q(domain_info__portfolio__isnull=False) & Q(domain_info__portfolio__federal_agency__isnull=False), + then=F("domain_info__portfolio__federal_agency__agency"), + ), + # Otherwise, return the natively assigned value + default=F("domain_info__federal_agency__agency"), + ), + converted_federal_type=Case( + # When portfolio is present, use its value instead + When( + Q(domain_info__portfolio__isnull=False) & Q(domain_info__portfolio__federal_agency__isnull=False), + then=F("domain_info__portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=F("domain_info__federal_agency__federal_type"), + ), + converted_organization_name=Case( + # When portfolio is present, use its value instead + When(domain_info__portfolio__isnull=False, then=F("domain_info__portfolio__organization_name")), + # Otherwise, return the natively assigned value + default=F("domain_info__organization_name"), + ), + converted_city=Case( + # When portfolio is present, use its value instead + When(domain_info__portfolio__isnull=False, then=F("domain_info__portfolio__city")), + # Otherwise, return the natively assigned value + default=F("domain_info__city"), + ), + converted_state_territory=Case( + # When portfolio is present, use its value instead + When(domain_info__portfolio__isnull=False, then=F("domain_info__portfolio__state_territory")), + # Otherwise, return the natively assigned value + default=F("domain_info__state_territory"), + ), + ) + + # Filters + list_filter = [GenericOrgFilter, FederalTypeFilter, ElectionOfficeFilter, "state"] + + # ------- END FILTERS + + # Inlines inlines = [DomainInformationInline] # Columns list_display = [ "name", - "generic_org_type", - "federal_type", - "federal_agency", - "organization_name", + "converted_generic_org_type", + "converted_federal_type", + "converted_federal_agency", + "converted_organization_name", "custom_election_board", - "city", - "state_territory", + "converted_city", + "converted_state_territory", "state", "expiration_date", "created_at", @@ -2723,28 +2884,81 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): ), ) + # ------- Domain Information Fields + + # --- Generic Org Type + # Use converted value in the table + @admin.display(description=_("Generic Org Type")) + def converted_generic_org_type(self, obj): + return obj.domain_info.converted_generic_org_type_display + + converted_generic_org_type.admin_order_field = "converted_generic_org_type" # type: ignore + + # Use native value for the change form def generic_org_type(self, obj): return obj.domain_info.get_generic_org_type_display() - generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore + # --- Federal Agency + @admin.display(description=_("Federal Agency")) + def converted_federal_agency(self, obj): + return obj.domain_info.converted_federal_agency + converted_federal_agency.admin_order_field = "converted_federal_agency" # type: ignore + + # Use native value for the change form def federal_agency(self, obj): if obj.domain_info: return obj.domain_info.federal_agency else: return None - federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore + # --- Federal Type + # Use converted value in the table + @admin.display(description=_("Federal Type")) + def converted_federal_type(self, obj): + return obj.domain_info.converted_federal_type_display + converted_federal_type.admin_order_field = "converted_federal_type" # type: ignore + + # Use native value for the change form def federal_type(self, obj): return obj.domain_info.federal_type if obj.domain_info else None - federal_type.admin_order_field = "domain_info__federal_type" # type: ignore + # --- Organization Name + # Use converted value in the table + @admin.display(description=_("Organization Name")) + def converted_organization_name(self, obj): + return obj.domain_info.converted_organization_name + converted_organization_name.admin_order_field = "converted_organization_name" # type: ignore + + # Use native value for the change form def organization_name(self, obj): return obj.domain_info.organization_name if obj.domain_info else None - organization_name.admin_order_field = "domain_info__organization_name" # type: ignore + # --- City + # Use converted value in the table + @admin.display(description=_("City")) + def converted_city(self, obj): + return obj.domain_info.converted_city + + converted_city.admin_order_field = "converted_city" # type: ignore + + # Use native value for the change form + def city(self, obj): + return obj.domain_info.city if obj.domain_info else None + + # --- State + # Use converted value in the table + @admin.display(description=_("State / territory")) + def converted_state_territory(self, obj): + return obj.domain_info.converted_state_territory + + converted_state_territory.admin_order_field = "converted_state_territory" # type: ignore + + # Use native value for the change form + def state_territory(self, obj): + return obj.domain_info.state_territory if obj.domain_info else None def dnssecdata(self, obj): return "Yes" if obj.dnssecdata else "No" @@ -2777,23 +2991,14 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): custom_election_board.admin_order_field = "domain_info__is_election_board" # type: ignore custom_election_board.short_description = "Election office" # type: ignore - def city(self, obj): - return obj.domain_info.city if obj.domain_info else None - - city.admin_order_field = "domain_info__city" # type: ignore - - @admin.display(description=_("State / territory")) - def state_territory(self, obj): - return obj.domain_info.state_territory if obj.domain_info else None - - state_territory.admin_order_field = "domain_info__state_territory" # type: ignore - - # Filters - list_filter = ["domain_info__generic_org_type", "domain_info__federal_type", ElectionOfficeFilter, "state"] - + # Search search_fields = ["name"] search_help_text = "Search by domain name." + + # Change Form change_form_template = "django/admin/domain_change_form.html" + + # Readonly Fields readonly_fields = ( "state", "expiration_date", @@ -3058,7 +3263,8 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): def get_queryset(self, request): """Custom get_queryset to filter by portfolio if portfolio is in the request params.""" - qs = super().get_queryset(request) + initial_qs = super().get_queryset(request) + qs = self.get_annotated_queryset(initial_qs) # Check if a 'portfolio' parameter is passed in the request portfolio_id = request.GET.get("portfolio") if portfolio_id: @@ -3579,6 +3785,14 @@ class WaffleFlagAdmin(FlagAdmin): model = models.WaffleFlag fields = "__all__" + # Hack to get the dns_prototype_flag to auto populate when you navigate to + # the waffle flag page. + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag") + return super().changelist_view(request, extra_context=extra_context) + class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin): list_display = ["name", "portfolio"] diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index 4621c5ac5..a815a59a1 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -15,8 +15,8 @@ function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, a // Revert the dropdown to its previous value statusDropdown.value = valueToCheck; }); - }else { - console.log("displayModalOnDropdownClick() -> Cancel button was null"); + } else { + console.warn("displayModalOnDropdownClick() -> Cancel button was null"); } // Add a change event listener to the dropdown. diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 5de02f35a..bd4bed01b 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -9,6 +9,7 @@ import { initDomainsTable } from './table-domains.js'; import { initDomainRequestsTable } from './table-domain-requests.js'; import { initMembersTable } from './table-members.js'; import { initMemberDomainsTable } from './table-member-domains.js'; +import { initEditMemberDomainsTable } from './table-edit-member-domains.js'; import { initPortfolioMemberPageToggle } from './portfolio-member-page.js'; import { initAddNewMemberPageListeners } from './portfolio-member-page.js'; @@ -41,6 +42,7 @@ initDomainsTable(); initDomainRequestsTable(); initMembersTable(); initMemberDomainsTable(); +initEditMemberDomainsTable(); initPortfolioMemberPageToggle(); initAddNewMemberPageListeners(); diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index ac0b7cffe..ba874cfb1 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -49,7 +49,7 @@ export function initPortfolioMemberPageToggle() { * on the Add New Member page. */ export function initAddNewMemberPageListeners() { - add_member_form = document.getElementById("add_member_form") + let add_member_form = document.getElementById("add_member_form"); if (!add_member_form){ return; } diff --git a/src/registrar/assets/src/js/getgov/table-base.js b/src/registrar/assets/src/js/getgov/table-base.js index 07b7cff5e..e526c6b5f 100644 --- a/src/registrar/assets/src/js/getgov/table-base.js +++ b/src/registrar/assets/src/js/getgov/table-base.js @@ -126,6 +126,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r export class BaseTable { constructor(itemName) { this.itemName = itemName; + this.displayName = itemName; this.sectionSelector = itemName + 's'; this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`); this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`); @@ -183,7 +184,7 @@ export class BaseTable { // Counter should only be displayed if there is more than 1 item paginationSelectorEl.classList.toggle('display-none', totalItems < 1); - counterSelectorEl.innerHTML = `${totalItems} ${this.itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; + counterSelectorEl.innerHTML = `${totalItems} ${this.displayName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; // Helper function to create a pagination item const createPaginationItem = (page) => { @@ -416,6 +417,11 @@ export class BaseTable { */ initShowMoreButtons(){} + /** + * See function for more details + */ + initCheckboxListeners(){} + /** * Loads rows in the members list, as well as updates pagination around the members list * based on the supplied attributes. @@ -431,7 +437,7 @@ export class BaseTable { let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); // --------- FETCH DATA - // fetch json of page of domains, given params + // fetch json of page of objects, given params const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; if (!baseUrlValue) return; @@ -462,6 +468,7 @@ export class BaseTable { }); this.initShowMoreButtons(); + this.initCheckboxListeners(); this.loadModals(data.page, data.total, data.unfiltered_total); diff --git a/src/registrar/assets/src/js/getgov/table-domain-requests.js b/src/registrar/assets/src/js/getgov/table-domain-requests.js index c005ed891..51e4ea12b 100644 --- a/src/registrar/assets/src/js/getgov/table-domain-requests.js +++ b/src/registrar/assets/src/js/getgov/table-domain-requests.js @@ -23,6 +23,7 @@ export class DomainRequestsTable extends BaseTable { constructor() { super('domain-request'); + this.displayName = "domain request"; } getBaseUrl() { diff --git a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js new file mode 100644 index 000000000..95492d46f --- /dev/null +++ b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js @@ -0,0 +1,234 @@ + +import { BaseTable } from './table-base.js'; + +/** + * EditMemberDomainsTable is used for PortfolioMember and PortfolioInvitedMember + * Domain Editing. + * + * This table has additional functionality for tracking and making changes + * to domains assigned to the member/invited member. + */ +export class EditMemberDomainsTable extends BaseTable { + + constructor() { + super('edit-member-domain'); + this.displayName = "domain"; + this.currentSortBy = 'name'; + this.initialDomainAssignments = []; // list of initially assigned domains + this.initialDomainAssignmentsOnlyMember = []; // list of initially assigned domains which are readonly + this.addedDomains = []; // list of domains added to member + this.removedDomains = []; // list of domains removed from member + this.initializeDomainAssignments(); + this.initCancelEditDomainAssignmentButton(); + } + getBaseUrl() { + return document.getElementById("get_member_domains_json_url"); + } + getDataObjects(data) { + return data.domains; + } + /** getDomainAssignmentSearchParams is used to prepare search to populate + * initialDomainAssignments and initialDomainAssignmentsOnlyMember + * + * searches with memberOnly True so that only domains assigned to the member are returned + */ + getDomainAssignmentSearchParams(portfolio) { + let searchParams = new URLSearchParams(); + let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null; + let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null; + let memberOnly = true; + if (portfolio) + searchParams.append("portfolio", portfolio); + if (emailValue) + searchParams.append("email", emailValue); + if (memberIdValue) + searchParams.append("member_id", memberIdValue); + if (memberOnly) + searchParams.append("member_only", memberOnly); + return searchParams; + } + /** getSearchParams extends base class getSearchParams. + * + * additional searchParam for this table is checkedDomains. This is used to allow + * for backend sorting by domains which are 'checked' in the form. + */ + getSearchParams(page, sortBy, order, searchTerm, status, portfolio) { + let searchParams = super.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); + // Add checkedDomains to searchParams + // Clone the initial domains to avoid mutating them + let checkedDomains = [...this.initialDomainAssignments]; + // Add IDs from addedDomains that are not already in checkedDomains + this.addedDomains.forEach(domain => { + if (!checkedDomains.includes(domain.id)) { + checkedDomains.push(domain.id); + } + }); + // Remove IDs from removedDomains + this.removedDomains.forEach(domain => { + const index = checkedDomains.indexOf(domain.id); + if (index !== -1) { + checkedDomains.splice(index, 1); + } + }); + // Append updated checkedDomain IDs to searchParams + if (checkedDomains.length > 0) { + searchParams.append("checkedDomainIds", checkedDomains.join(",")); + } + return searchParams; + } + addRow(dataObject, tbody, customTableOptions) { + const domain = dataObject; + const row = document.createElement('tr'); + let checked = false; + let disabled = false; + if ( + (this.initialDomainAssignments.includes(domain.id) || + this.addedDomains.map(obj => obj.id).includes(domain.id)) && + !this.removedDomains.map(obj => obj.id).includes(domain.id) + ) { + checked = true; + } + if (this.initialDomainAssignmentsOnlyMember.includes(domain.id)) { + disabled = true; + } + + row.innerHTML = ` + +
+ + +
+ + + ${domain.name} + ${disabled ? 'Domains must have one domain manager. To unassign this member, the domain needs another domain manager.' : ''} + + `; + tbody.appendChild(row); + } + /** + * initializeDomainAssignments searches via ajax on page load for domains assigned to + * member. It populates both initialDomainAssignments and initialDomainAssignmentsOnlyMember. + * It is called once per page load, but not called with subsequent table changes. + */ + initializeDomainAssignments() { + const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; + if (!baseUrlValue) return; + let searchParams = this.getDomainAssignmentSearchParams(this.portfolioValue); + let url = baseUrlValue + "?" + searchParams.toString(); + fetch(url) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error('Error in AJAX call: ' + data.error); + return; + } + + let dataObjects = this.getDataObjects(data); + // Map the id attributes of dataObjects to this.initialDomainAssignments + this.initialDomainAssignments = dataObjects.map(obj => obj.id); + this.initialDomainAssignmentsOnlyMember = dataObjects + .filter(obj => obj.member_is_only_manager) + .map(obj => obj.id); + }) + .catch(error => console.error('Error fetching domain assignments:', error)); + } + /** + * Initializes listeners on checkboxes in the table. Checkbox listeners are used + * in this case to track changes to domain assignments in js (addedDomains and removedDomains) + * before changes are saved. + * initCheckboxListeners is called each time table is loaded. + */ + initCheckboxListeners() { + const checkboxes = this.tableWrapper.querySelectorAll('input[type="checkbox"]'); + checkboxes.forEach(checkbox => { + checkbox.addEventListener('change', () => { + const domain = { id: +checkbox.value, name: checkbox.name }; + + if (checkbox.checked) { + this.updateDomainLists(domain, this.removedDomains, this.addedDomains); + } else { + this.updateDomainLists(domain, this.addedDomains, this.removedDomains); + } + }); + }); + } + /** + * Helper function which updates domain lists. When called, if domain is in the fromList, + * it removes it; if domain is not in the toList, it is added to the toList. + * @param {*} domain - object containing the domain id and name + * @param {*} fromList - list of domains + * @param {*} toList - list of domains + */ + updateDomainLists(domain, fromList, toList) { + const index = fromList.findIndex(item => item.id === domain.id && item.name === domain.name); + + if (index > -1) { + fromList.splice(index, 1); // Remove from the `fromList` if it exists + } else { + toList.push(domain); // Add to the `toList` if not already there + } + } + /** + * initializes the Cancel button on the Edit domains page. + * Cancel triggers modal in certain conditions and the initialization for the modal is done + * in this function. + */ + initCancelEditDomainAssignmentButton() { + const cancelEditDomainAssignmentButton = document.getElementById('cancel-edit-domain-assignments'); + if (!cancelEditDomainAssignmentButton) { + console.error("Expected element #cancel-edit-domain-assignments, but it does not exist."); + return; // Exit early if the button doesn't exist + } + + // Find the last breadcrumb link + const lastPageLinkElement = document.querySelector('.usa-breadcrumb__list-item:nth-last-child(2) a'); + const lastPageLink = lastPageLinkElement ? lastPageLinkElement.getAttribute('href') : null; + + const hiddenModalTrigger = document.getElementById("hidden-cancel-edit-domain-assignments-modal-trigger"); + + if (!lastPageLink) { + console.warn("Last breadcrumb link not found or missing href."); + } + if (!hiddenModalTrigger) { + console.warn("Hidden modal trigger not found."); + } + + // Add click event listener + cancelEditDomainAssignmentButton.addEventListener('click', () => { + if (this.addedDomains.length || this.removedDomains.length) { + console.log('Changes detected. Triggering modal...'); + hiddenModalTrigger.click(); + } else if (lastPageLink) { + window.location.href = lastPageLink; // Redirect to the last breadcrumb link + } else { + console.warn("No changes detected, but no valid lastPageLink to navigate to."); + + } + }); + } + +} + +export function initEditMemberDomainsTable() { + document.addEventListener('DOMContentLoaded', function() { + const isEditMemberDomainsPage = document.getElementById("edit-member-domains"); + if (isEditMemberDomainsPage) { + const editMemberDomainsTable = new EditMemberDomainsTable(); + if (editMemberDomainsTable.tableWrapper) { + // Initial load + editMemberDomainsTable.loadTable(1); + } + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/table-member-domains.js b/src/registrar/assets/src/js/getgov/table-member-domains.js index 7d235f6e5..54e9d1212 100644 --- a/src/registrar/assets/src/js/getgov/table-member-domains.js +++ b/src/registrar/assets/src/js/getgov/table-member-domains.js @@ -5,6 +5,7 @@ export class MemberDomainsTable extends BaseTable { constructor() { super('member-domain'); + this.displayName = "domain"; this.currentSortBy = 'name'; } getBaseUrl() { diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss index ed2d5685b..45f0b5245 100644 --- a/src/registrar/assets/src/sass/_theme/_tables.scss +++ b/src/registrar/assets/src/sass/_theme/_tables.scss @@ -73,11 +73,15 @@ th { } } - td, th, - .usa-tabel th{ + td, th { padding: units(2) units(4) units(2) 0; } + // Hack fix to the overly specific selector above that broke utility class usefulness + .padding-right-105 { + padding-right: .75rem; + } + thead tr:first-child th:first-child { border-top: none; } diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index a18a813f1..050950c9b 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -86,6 +86,11 @@ secret_registry_key = b64decode(secret("REGISTRY_KEY", "")) secret_registry_key_passphrase = secret("REGISTRY_KEY_PASSPHRASE", "") secret_registry_hostname = secret("REGISTRY_HOSTNAME") +# PROTOTYPE: Used for DNS hosting +secret_registry_tenant_key = secret("REGISTRY_TENANT_KEY", None) +secret_registry_tenant_name = secret("REGISTRY_TENANT_NAME", None) +secret_registry_service_email = secret("REGISTRY_SERVICE_EMAIL", None) + # region: Basic Django Config-----------------------------------------------### # Build paths inside the project like this: BASE_DIR / "subdir". @@ -685,6 +690,9 @@ SECRET_REGISTRY_CERT = secret_registry_cert SECRET_REGISTRY_KEY = secret_registry_key SECRET_REGISTRY_KEY_PASSPHRASE = secret_registry_key_passphrase SECRET_REGISTRY_HOSTNAME = secret_registry_hostname +SECRET_REGISTRY_TENANT_KEY = secret_registry_tenant_key +SECRET_REGISTRY_TENANT_NAME = secret_registry_tenant_name +SECRET_REGISTRY_SERVICE_EMAIL = secret_registry_service_email # endregion # region: Security and Privacy----------------------------------------------### @@ -816,7 +824,9 @@ SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SECURE = True # session engine to cache session information -SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_ENGINE = "django.contrib.sessions.backends.db" + +SESSION_SERIALIZER = "django.contrib.sessions.serializers.PickleSerializer" # ~ Set by django.middleware.clickjacking.XFrameOptionsMiddleware # prevent clickjacking by instructing the browser not to load diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 53b83e564..66708c571 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -46,8 +46,8 @@ DOMAIN_REQUEST_NAMESPACE = views.DomainRequestWizard.URL_NAMESPACE # dynamically generate the other domain_request_urls domain_request_urls = [ path("", RedirectView.as_view(pattern_name="domain-request:start"), name="redirect-to-start"), - path("start/", views.DomainRequestWizard.as_view(), name="start"), - path("finished/", views.Finished.as_view(), name="finished"), + path("start/", views.DomainRequestWizard.as_view(), name=views.DomainRequestWizard.NEW_URL_NAME), + path("finished/", views.Finished.as_view(), name=views.DomainRequestWizard.FINISHED_URL_NAME), ] for step, view in [ # add/remove steps here @@ -109,6 +109,11 @@ urlpatterns = [ views.PortfolioMemberDomainsView.as_view(), name="member-domains", ), + path( + "member//domains/edit", + views.PortfolioMemberDomainsEditView.as_view(), + name="member-domains-edit", + ), path( "invitedmember/", views.PortfolioInvitedMemberView.as_view(), @@ -129,6 +134,11 @@ urlpatterns = [ views.PortfolioInvitedMemberDomainsView.as_view(), name="invitedmember-domains", ), + path( + "invitedmember//domains/edit", + views.PortfolioInvitedMemberDomainsEditView.as_view(), + name="invitedmember-domains-edit", + ), # path( # "no-organization-members/", # views.PortfolioNoMembersView.as_view(), @@ -255,11 +265,6 @@ urlpatterns = [ ExportDataTypeRequests.as_view(), name="export_data_type_requests", ), - path( - "reports/export_data_type_requests/", - ExportDataTypeRequests.as_view(), - name="export_data_type_requests", - ), path( "domain-request//edit/", views.DomainRequestWizard.as_view(), @@ -298,6 +303,7 @@ urlpatterns = [ name="todo", ), path("domain/", views.DomainView.as_view(), name="domain"), + path("domain//prototype-dns", views.PrototypeDomainDNSRecordView.as_view(), name="prototype-domain-dns"), path("domain//users", views.DomainUsersView.as_view(), name="domain-users"), path( "domain//dns", diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index c1547ad88..9f5d0162f 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -99,7 +99,7 @@ def portfolio_permissions(request): def is_widescreen_mode(request): - widescreen_paths = [] + widescreen_paths = [] # If this list is meant to include specific paths, populate it. portfolio_widescreen_paths = [ "/domains/", "/requests/", @@ -108,10 +108,21 @@ def is_widescreen_mode(request): "/no-organization-domains/", "/domain-request/", ] + # widescreen_paths can be a bear as it trickles down sub-urls. exclude_paths gives us a way out. + exclude_paths = [ + "/domains/edit", + ] + + # Check if the current path matches a widescreen path or the root path. is_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/" - is_portfolio_widescreen = bool( + + # Check if the user is an organization user and the path matches portfolio paths. + is_portfolio_widescreen = ( hasattr(request.user, "is_org_user") and request.user.is_org_user(request) and any(path in request.path for path in portfolio_widescreen_paths) + and not any(exclude_path in request.path for exclude_path in exclude_paths) ) + + # Return a dictionary with the widescreen mode status. return {"is_widescreen_mode": is_widescreen or is_portfolio_widescreen} diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index e55c40858..572ef6399 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -527,7 +527,12 @@ class DotGovDomainForm(RegistrarForm): class PurposeForm(RegistrarForm): purpose = forms.CharField( label="Purpose", - widget=forms.Textarea(), + widget=forms.Textarea( + attrs={ + "aria-label": "What is the purpose of your requested domain? Describe how you’ll use your .gov domain. \ + Will it be used for a website, email, or something else? You can enter up to 2000 characters." + } + ), validators=[ MaxLengthValidator( 2000, @@ -794,6 +799,22 @@ class AnythingElseForm(BaseDeletableRegistrarForm): ) +class PortfolioAnythingElseForm(BaseDeletableRegistrarForm): + """The form for the portfolio additional details page. Tied to the anything_else field.""" + + anything_else = forms.CharField( + required=False, + label="Anything else?", + widget=forms.Textarea(), + validators=[ + MaxLengthValidator( + 2000, + message="Response must be less than 2000 characters.", + ) + ], + ) + + class AnythingElseYesNoForm(BaseYesNoForm): """Yes/no toggle for the anything else question on additional details""" diff --git a/src/registrar/management/commands/load_transition_domain.py b/src/registrar/management/commands/load_transition_domain.py index c2dd66f55..c39856c00 100644 --- a/src/registrar/management/commands/load_transition_domain.py +++ b/src/registrar/management/commands/load_transition_domain.py @@ -295,7 +295,6 @@ class Command(BaseCommand): except Exception as err: logger.error(f"Could not load additional TransitionDomain data. {err}") raise err - # TODO: handle this better...needs more logging def handle( # noqa: C901 self, diff --git a/src/registrar/management/commands/master_domain_migrations.py b/src/registrar/management/commands/master_domain_migrations.py index 9cb469078..7f702e047 100644 --- a/src/registrar/management/commands/master_domain_migrations.py +++ b/src/registrar/management/commands/master_domain_migrations.py @@ -29,9 +29,6 @@ logger = logging.getLogger(__name__) class Command(BaseCommand): help = """ """ # TODO: update this! - # ====================================================== - # ================== ARGUMENTS =================== - # ====================================================== def add_arguments(self, parser): """ OPTIONAL ARGUMENTS: diff --git a/src/registrar/migrations/0139_alter_domainrequest_action_needed_reason.py b/src/registrar/migrations/0139_alter_domainrequest_action_needed_reason.py new file mode 100644 index 000000000..c3af6905e --- /dev/null +++ b/src/registrar/migrations/0139_alter_domainrequest_action_needed_reason.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.10 on 2024-11-27 21:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0138_alter_domaininvitation_status"), + ] + + operations = [ + migrations.AlterField( + model_name="domainrequest", + name="action_needed_reason", + field=models.TextField( + blank=True, + choices=[ + ("eligibility_unclear", "Unclear organization eligibility"), + ("questionable_senior_official", "Questionable senior official"), + ("already_has_a_domain", "Already has a domain"), + ("bad_name", "Doesn’t meet naming requirements"), + ("other", "Other (no auto-email sent)"), + ], + null=True, + ), + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index f67002e4f..9aca9b5c3 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -4,7 +4,6 @@ import ipaddress import re from datetime import date from typing import Optional - from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore from django.db import models diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 7dadf26ac..b1c4fd806 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -426,13 +426,14 @@ class DomainInformation(TimeStampedModel): else: return None + # ----- Portfolio Properties ----- + @property def converted_organization_name(self): if self.portfolio: return self.portfolio.organization_name return self.organization_name - # ----- Portfolio Properties ----- @property def converted_generic_org_type(self): if self.portfolio: @@ -454,20 +455,20 @@ class DomainInformation(TimeStampedModel): @property def converted_senior_official(self): if self.portfolio: - return self.portfolio.senior_official - return self.senior_official + return self.portfolio.display_senior_official + return self.display_senior_official @property def converted_address_line1(self): if self.portfolio: - return self.portfolio.address_line1 - return self.address_line1 + return self.portfolio.display_address_line1 + return self.display_address_line1 @property def converted_address_line2(self): if self.portfolio: - return self.portfolio.address_line2 - return self.address_line2 + return self.portfolio.display_address_line2 + return self.display_address_line2 @property def converted_city(self): @@ -478,17 +479,30 @@ class DomainInformation(TimeStampedModel): @property def converted_state_territory(self): if self.portfolio: - return self.portfolio.state_territory - return self.state_territory + return self.portfolio.get_state_territory_display() + return self.get_state_territory_display() @property def converted_zipcode(self): if self.portfolio: - return self.portfolio.zipcode - return self.zipcode + return self.portfolio.display_zipcode + return self.display_zipcode @property def converted_urbanization(self): if self.portfolio: - return self.portfolio.urbanization - return self.urbanization + return self.portfolio.display_urbanization + return self.display_urbanization + + # ----- Portfolio Properties (display values)----- + @property + def converted_generic_org_type_display(self): + if self.portfolio: + return self.portfolio.get_organization_type_display() + return self.get_generic_org_type_display() + + @property + def converted_federal_type_display(self): + if self.portfolio: + return self.portfolio.federal_agency.get_federal_type_display() + return self.get_federal_type_display() diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 0d8bbd5cf..44d8511b0 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -280,7 +280,7 @@ class DomainRequest(TimeStampedModel): ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility") QUESTIONABLE_SENIOR_OFFICIAL = ("questionable_senior_official", "Questionable senior official") - ALREADY_HAS_DOMAINS = ("already_has_domains", "Already has domains") + ALREADY_HAS_A_DOMAIN = ("already_has_a_domain", "Already has a domain") BAD_NAME = ("bad_name", "Doesn’t meet naming requirements") OTHER = ("other", "Other (no auto-email sent)") @@ -1437,6 +1437,18 @@ class DomainRequest(TimeStampedModel): return self.portfolio.federal_type return self.federal_type + @property + def converted_address_line1(self): + if self.portfolio: + return self.portfolio.address_line1 + return self.address_line1 + + @property + def converted_address_line2(self): + if self.portfolio: + return self.portfolio.address_line2 + return self.address_line2 + @property def converted_city(self): if self.portfolio: @@ -1449,8 +1461,33 @@ class DomainRequest(TimeStampedModel): return self.portfolio.state_territory return self.state_territory + @property + def converted_urbanization(self): + if self.portfolio: + return self.portfolio.urbanization + return self.urbanization + + @property + def converted_zipcode(self): + if self.portfolio: + return self.portfolio.zipcode + return self.zipcode + @property def converted_senior_official(self): if self.portfolio: return self.portfolio.senior_official return self.senior_official + + # ----- Portfolio Properties (display values)----- + @property + def converted_generic_org_type_display(self): + if self.portfolio: + return self.portfolio.get_organization_type_display() + return self.get_generic_org_type_display() + + @property + def converted_federal_type_display(self): + if self.portfolio: + return self.portfolio.federal_agency.get_federal_type_display() + return self.get_federal_type_display() diff --git a/src/registrar/templates/401.html b/src/registrar/templates/401.html index 20ca0420e..d7c7f83ae 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 ef910a191..999d5f98e 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 024c2803b..471575558 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 95c17e069..a0663816b 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/django/forms/widgets/input.html b/src/registrar/templates/django/forms/widgets/input.html index e7b43655d..91f5b20ea 100644 --- a/src/registrar/templates/django/forms/widgets/input.html +++ b/src/registrar/templates/django/forms/widgets/input.html @@ -5,6 +5,5 @@ class="{{ uswds_input_class }}{% if classes %} {{ classes }}{% endif %}" {% if widget.value != None %}value="{{ widget.value|stringformat:'s' }}"{% endif %} {% if aria_label %}aria-label="{{ aria_label }} {{ label }}"{% endif %} - {% if sublabel_text %}aria-describedby="{{ widget.attrs.id }}__sublabel"{% endif %} {% include "django/forms/widgets/attrs.html" %} /> diff --git a/src/registrar/templates/domain_dns.html b/src/registrar/templates/domain_dns.html index 9a2070c64..c8ece2e32 100644 --- a/src/registrar/templates/domain_dns.html +++ b/src/registrar/templates/domain_dns.html @@ -28,6 +28,7 @@

The Domain Name System (DNS) is the internet service that translates your domain name into an IP address. Before your .gov domain can be used, you'll need to connect it to a DNS hosting service and provide us with your name server information.

You can enter your name servers, as well as other DNS-related information, in the following sections:

+ {% url 'domain-dns-nameservers' pk=domain.id as url %}
    @@ -35,6 +36,9 @@ {% url 'domain-dns-dnssec' pk=domain.id as url %}
  • DNSSEC
  • + {% if dns_prototype_flag and is_valid_domain %} +
  • Prototype DNS record creator
  • + {% endif %}
{% endblock %} {# domain_content #} diff --git a/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt b/src/registrar/templates/emails/action_needed_reasons/already_has_a_domain.txt similarity index 100% rename from src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt rename to src/registrar/templates/emails/action_needed_reasons/already_has_a_domain.txt diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index b00c57b5c..b1c3775df 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/footer.html b/src/registrar/templates/includes/footer.html index c8d237821..74ef3dc50 100644 --- a/src/registrar/templates/includes/footer.html +++ b/src/registrar/templates/includes/footer.html @@ -3,7 +3,7 @@