diff --git a/README.md b/README.md index 2685a8ec3..f457e7e69 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Get (your very own) .gov +# Infrastructure as a (public) service -Welcome to the repo for a WIP brand new registrar for .gov domains. Get.gov intends to serve all government entities in the United States looking for a .gov domain to use publicly (for a website, for an email address, etc.). Here you can find the code for the registrar and other artifacts about our product strategy and research. +The .gov domain helps U.S.-based government organizations gain public trust by being easily recognized online. This repo contains the code for the new .gov registrar – where governments request and manage domains – and other artifacts about our product strategy and research. ## Onboarding diff --git a/ops/manifests/manifest-ab.yaml b/ops/manifests/manifest-ab.yaml index 463563a15..fb8b02b03 100644 --- a/ops/manifests/manifest-ab.yaml +++ b/ops/manifests/manifest-ab.yaml @@ -20,8 +20,8 @@ applications: DJANGO_BASE_URL: https://getgov-ab.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO - # Public site base URL - GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/ + # default public site location + GETGOV_PUBLIC_SITE_URL: https://beta.get.gov routes: - route: getgov-ab.app.cloud.gov services: diff --git a/ops/manifests/manifest-bl.yaml b/ops/manifests/manifest-bl.yaml index ec6d627ce..bff347709 100644 --- a/ops/manifests/manifest-bl.yaml +++ b/ops/manifests/manifest-bl.yaml @@ -20,8 +20,8 @@ applications: DJANGO_BASE_URL: https://getgov-bl.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO - # Public site base URL - GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/ + # default public site location + GETGOV_PUBLIC_SITE_URL: https://beta.get.gov routes: - route: getgov-bl.app.cloud.gov services: diff --git a/ops/manifests/manifest-dk.yaml b/ops/manifests/manifest-dk.yaml index 87de8a496..249eab119 100644 --- a/ops/manifests/manifest-dk.yaml +++ b/ops/manifests/manifest-dk.yaml @@ -20,8 +20,8 @@ applications: DJANGO_BASE_URL: https://getgov-dk.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO - # Public site base URL - GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/ + # default public site location + GETGOV_PUBLIC_SITE_URL: https://beta.get.gov routes: - route: getgov-dk.app.cloud.gov services: diff --git a/ops/manifests/manifest-rjm.yaml b/ops/manifests/manifest-rjm.yaml index 9622f09bc..1942414ef 100644 --- a/ops/manifests/manifest-rjm.yaml +++ b/ops/manifests/manifest-rjm.yaml @@ -20,8 +20,8 @@ applications: DJANGO_BASE_URL: https://getgov-rjm.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO - # Public site base URL - GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/ + # default public site location + GETGOV_PUBLIC_SITE_URL: https://beta.get.gov routes: - route: getgov-rjm.app.cloud.gov services: diff --git a/ops/manifests/manifest-stable.yaml b/ops/manifests/manifest-stable.yaml index 72726cd08..7cfa1417d 100644 --- a/ops/manifests/manifest-stable.yaml +++ b/ops/manifests/manifest-stable.yaml @@ -20,8 +20,8 @@ applications: DJANGO_BASE_URL: https://getgov-stable.app.cloud.gov # Tell Django how much stuff to log DJANGO_LOG_LEVEL: INFO - # Public site base URL - GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/ + # default public site location + GETGOV_PUBLIC_SITE_URL: https://beta.get.gov routes: - route: getgov-stable.app.cloud.gov services: diff --git a/ops/scripts/create_environment_migrate.sh b/ops/scripts/create_environment_migrate.sh new file mode 100755 index 000000000..0e9d8aff7 --- /dev/null +++ b/ops/scripts/create_environment_migrate.sh @@ -0,0 +1,128 @@ +# This script sets up a completely new Cloud.gov CF Space with all the corresponding +# infrastructure needed to run get.gov. It can serve for documentation for running +# NOTE: This script was written for MacOS and to be run at the root directory +# of the repository. It uses `docker compose` to compile assets, so it is +# safest to `docker compose down` before using this script. + +if [ -z "$1" ]; then + echo 'Please specify a name on the command line for the new space (i.e. lmm)' >&2 + exit 1 +fi + +if [ ! $(command -v gh) ] || [ ! $(command -v jq) ] || [ ! $(command -v cf) ]; then + echo "jq, cf, and gh packages must be installed. Please install via your preferred manager." + exit 1 +fi + +upcase_name=$(printf "%s" "$1" | tr '[:lower:]' '[:upper:]') + +read -p "Are you on a new branch? We will have to commit this work. (y/n) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]] +then + git checkout -b new-environment-$1 +fi + +cf target -o cisa-dotgov + +read -p "Are you logged in to the cisa-dotgov CF org above? (y/n) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]] +then + cf login -a https://api.fr.cloud.gov --sso +fi + +gh auth status +read -p "Are you logged into a Github account with access to cisagov/getgov? (y/n) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]] +then + gh auth login +fi + +echo "Creating manifest for $1..." +cp ops/scripts/manifest-sandbox-template-migrate.yaml ops/manifests/manifest-$1.yaml +sed -i '' "s/ENVIRONMENT/$1/" "ops/manifests/manifest-$1.yaml" + +echo "Creating new cloud.gov space for $1..." +cf create-space $1 +cf target -o "cisa-dotgov" -s $1 +cf bind-security-group public_networks_egress cisa-dotgov --space $1 +cf bind-security-group trusted_local_networks_egress cisa-dotgov --space $1 + +echo "Creating new cloud.gov DB for $1. This usually takes about 5 minutes..." +cf create-service aws-rds micro-psql getgov-$1-database + +until cf service getgov-$1-database | grep -q 'The service instance status is succeeded' +do + echo "Database not up yet, waiting..." + sleep 30 +done + +echo "Creating new cloud.gov credentials for $1..." +django_key=$(python3 -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())') +openssl req -nodes -x509 -days 365 -newkey rsa:2048 -keyout private-$1.pem -out public-$1.crt +login_key=$(base64 -i private-$1.pem) +jq -n --arg django_key "$django_key" --arg login_key "$login_key" '{"DJANGO_SECRET_KEY":$django_key,"DJANGO_SECRET_LOGIN_KEY":$login_key}' > credentials-$1.json +cf cups getgov-credentials -p credentials-$1.json + +echo "Now you will need to update some things for Login. Please sign-in to https://dashboard.int.identitysandbox.gov/." +echo "Navigate to our application config: https://dashboard.int.identitysandbox.gov/service_providers/2640/edit?" +echo "There are two things to update." +echo "1. You need to upload the public-$1.crt file generated as part of the previous command." +echo "2. You need to add two redirect URIs: https://getgov-$1.app.cloud.gov/openid/callback/login/ and +https://getgov-$1.app.cloud.gov/openid/callback/logout/ to the list of URIs." +read -p "Please confirm when this is done (y/n) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]] +then + exit 1 +fi + +echo "Database create succeeded and credentials created. Deploying the get.gov application to the new space $1..." +echo "Building assets..." +open -a Docker +cd src/ +./build.sh +cd .. +cf push getgov-$1 -f ops/manifests/manifest-$1.yaml + +read -p "Please provide the email of the space developer: " -r +cf set-space-role $REPLY cisa-dotgov $1 SpaceDeveloper + +read -p "Should we run migrations? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + cf run-task getgov-$1 --command 'python manage.py migrate' --name migrate +fi + +echo "Alright, your app is up and running at https://getgov-$1.app.cloud.gov!" +echo +echo "Moving on to setup Github automation..." + +echo "Creating space deployer for Github deploys..." +cf create-service cloud-gov-service-account space-deployer github-cd-account +cf create-service-key github-cd-account github-cd-key +cf service-key github-cd-account github-cd-key +read -p "Please confirm we should set the above username and key to Github secrets. (y/n) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]] +then + exit 1 +fi + +cf service-key github-cd-account github-cd-key | sed 1,2d | jq -r '[.credentials.username, .credentials.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 +done + +read -p "All done! Should we open a PR with these changes? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + git add ops/manifests/manifest-$1.yaml .github/workflows/ src/registrar/config/settings.py + git commit -m "Add new developer sandbox '"$1"' infrastructure" + gh pr create +fi diff --git a/ops/scripts/manifest-sandbox-template-migrate.yaml b/ops/scripts/manifest-sandbox-template-migrate.yaml new file mode 100644 index 000000000..8789effa5 --- /dev/null +++ b/ops/scripts/manifest-sandbox-template-migrate.yaml @@ -0,0 +1,30 @@ +--- +applications: +- name: getgov-ENVIRONMENT + 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 + 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-ENVIRONMENT.app.cloud.gov + # Tell Django how much stuff to log + DJANGO_LOG_LEVEL: INFO + # default public site location + GETGOV_PUBLIC_SITE_URL: https://beta.get.gov + # use a non-default route to avoid conflicts + routes: + - route: getgov-ENVIRONMENT-migrate.app.cloud.gov + services: + - getgov-credentials + - getgov-ENVIRONMENT-database diff --git a/ops/scripts/migrate_new_old.sh b/ops/scripts/migrate_new_old.sh new file mode 100755 index 000000000..1ed56dcbf --- /dev/null +++ b/ops/scripts/migrate_new_old.sh @@ -0,0 +1,36 @@ +# This script sets up a completely new Cloud.gov CF Space with all the corresponding +# infrastructure needed to run get.gov. It can serve for documentation for running +# NOTE: This script was written for MacOS and to be run at the root directory +# of the repository. + +if [ -z "$1" ]; then + echo 'Please specify a name on the command line for the new space (i.e. lmm)' >&2 + exit 1 +fi + +# The user running this script has to be a SpaceDeveloper on both the +# new and old org/spaces. + +OLD_ORG="cisa-getgov-prototyping" +NEW_ORG="cisa-dotgov" + +# +# delete old route +cf target -o $OLD_ORG -s $1 +cf delete-route app.cloud.gov -n getgov-$1 + +# re-claim the route on new orf +cf target -o $NEW_ORG -s $1 +cf map-route getgov-$1 app.cloud.gov -n getgov-$1 +cf delete-route app.cloud.gov -n getgov-$1-migrate + +# delete old app and services +cf target -o $OLD_ORG -s $1 +cf delete getgov-$1 +cf delete-service getgov-$1-database +cf delete-service getgov-credentials +cf delete-service getgov-cd-account +cf delete-space $1 + + +printf "Remove -migrate from ops/manifests/manifest-$1.yaml" diff --git a/src/djangooidc/oidc.py b/src/djangooidc/oidc.py index 26eb54d3d..286e519a4 100644 --- a/src/djangooidc/oidc.py +++ b/src/djangooidc/oidc.py @@ -238,6 +238,9 @@ class Client(oic.Client): "client_secret": self.client_secret, }, authn_method=self.registration_response["token_endpoint_auth_method"], + # There is a time desync issue between login.gov and cloud + # this addresses that by adding a clock skew. + skew=10, ) except Exception as err: logger.error(err) diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 82642bc93..786f4c24b 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -54,6 +54,7 @@ services: # command: "python" command: > bash -c " python manage.py migrate && + python manage.py load && python manage.py runserver 0.0.0.0:8080" db: diff --git a/src/registrar/admin.py b/src/registrar/admin.py index bf0bc28f0..857f04d82 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -104,6 +104,36 @@ class MyUserAdmin(BaseUserAdmin): inlines = [UserContactInline] + list_display = ( + "email", + "first_name", + "last_name", + "is_staff", + "is_superuser", + "status", + ) + + fieldsets = ( + ( + None, + {"fields": ("username", "password", "status")}, + ), + ("Personal Info", {"fields": ("first_name", "last_name", "email")}), + ( + "Permissions", + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ("Important dates", {"fields": ("last_login", "date_joined")}), + ) + def get_list_display(self, request): if not request.user.is_superuser: # Customize the list display for staff users @@ -146,7 +176,7 @@ class DomainAdmin(ListHeaderAdmin): readonly_fields = ["state"] def response_change(self, request, obj): - ACTION_BUTTON = "_place_client_hold" + GET_SECURITY_EMAIL = "_get_security_email" SET_SECURITY_CONTACT = "_set_security_contact" MAKE_DOMAIN = "_make_domain_in_registry" @@ -157,7 +187,10 @@ class DomainAdmin(ListHeaderAdmin): REMOVE_CLIENT_HOLD = "_rem_client_hold" DELETE_DOMAIN = "_delete_domain" - if ACTION_BUTTON in request.POST: + + PLACE_HOLD = "_place_client_hold" + EDIT_DOMAIN = "_edit_domain" + if PLACE_HOLD in request.POST: try: obj.place_client_hold() except Exception as err: @@ -195,7 +228,7 @@ class DomainAdmin(ListHeaderAdmin): ) return HttpResponseRedirect(".") - if SET_SECURITY_CONTACT in request.POST: + elif SET_SECURITY_CONTACT in request.POST: try: fake_email = "manuallyEnteredEmail@test.gov" if PublicContact.objects.filter( @@ -218,7 +251,7 @@ class DomainAdmin(ListHeaderAdmin): ("The security email is %" ". Thanks!") % fake_email, ) - if MAKE_DOMAIN in request.POST: + elif MAKE_DOMAIN in request.POST: try: obj._get_or_create_domain() except Exception as err: @@ -230,9 +263,8 @@ class DomainAdmin(ListHeaderAdmin): ) return HttpResponseRedirect(".") - # make nameservers here - if MAKE_NAMESERVERS in request.POST: + elif MAKE_NAMESERVERS in request.POST: try: hosts = [("ns1.example.com", None), ("ns2.example.com", None)] obj.nameservers = hosts @@ -244,7 +276,7 @@ class DomainAdmin(ListHeaderAdmin): ("Hosts set to be %s" ". Thanks!") % hosts, ) return HttpResponseRedirect(".") - if GET_NAMESERVERS in request.POST: + elif GET_NAMESERVERS in request.POST: try: nameservers = obj.nameservers except Exception as err: @@ -256,7 +288,7 @@ class DomainAdmin(ListHeaderAdmin): ) return HttpResponseRedirect(".") - if GET_STATUS in request.POST: + elif GET_STATUS in request.POST: try: statuses = obj.statuses except Exception as err: @@ -268,7 +300,7 @@ class DomainAdmin(ListHeaderAdmin): ) return HttpResponseRedirect(".") - if SET_CLIENT_HOLD in request.POST: + elif SET_CLIENT_HOLD in request.POST: try: obj.clientHold() obj.save() @@ -281,7 +313,7 @@ class DomainAdmin(ListHeaderAdmin): ) return HttpResponseRedirect(".") - if REMOVE_CLIENT_HOLD in request.POST: + elif REMOVE_CLIENT_HOLD in request.POST: try: obj.revertClientHold() obj.save() @@ -293,7 +325,8 @@ class DomainAdmin(ListHeaderAdmin): ("Domain %s will now have client hold removed") % obj.name, ) return HttpResponseRedirect(".") - if DELETE_DOMAIN in request.POST: + + elif DELETE_DOMAIN in request.POST: try: obj.deleted() obj.save() @@ -305,8 +338,30 @@ class DomainAdmin(ListHeaderAdmin): ("Domain %s Should now be deleted " ". Thanks!") % obj.name, ) return HttpResponseRedirect(".") + elif EDIT_DOMAIN in request.POST: + # We want to know, globally, when an edit action occurs + request.session["analyst_action"] = "edit" + # Restricts this action to this domain (pk) only + request.session["analyst_action_location"] = obj.id + return HttpResponseRedirect(reverse("domain", args=(obj.id,))) return super().response_change(request, obj) + def change_view(self, request, object_id): + # If the analyst was recently editing a domain page, + # delete any associated session values + if "analyst_action" in request.session: + del request.session["analyst_action"] + del request.session["analyst_action_location"] + return super().change_view(request, object_id) + + def has_change_permission(self, request, obj=None): + # Fixes a bug wherein users which are only is_staff + # can access 'change' when GET, + # but cannot access this page when it is a request of type POST. + if request.user.is_staff: + return True + return super().has_change_permission(request, obj) + class ContactAdmin(ListHeaderAdmin): """Custom contact admin class to add search.""" @@ -319,6 +374,10 @@ class DomainApplicationAdmin(ListHeaderAdmin): """Customize the applications listing view.""" + # Set multi-selects 'read-only' (hide selects and show data) + # based on user perms and application creator's status + # form = DomainApplicationForm + # Columns list_display = [ "requested_domain", @@ -392,7 +451,7 @@ class DomainApplicationAdmin(ListHeaderAdmin): ] # Read only that we'll leverage for CISA Analysts - readonly_fields = [ + analyst_readonly_fields = [ "creator", "type_of_work", "more_organization_information", @@ -410,49 +469,81 @@ class DomainApplicationAdmin(ListHeaderAdmin): # Trigger action when a fieldset is changed def save_model(self, request, obj, form, change): - if change: # Check if the application is being edited - # Get the original application from the database - original_obj = models.DomainApplication.objects.get(pk=obj.pk) + if obj and obj.creator.status != models.User.RESTRICTED: + if change: # Check if the application is being edited + # Get the original application from the database + original_obj = models.DomainApplication.objects.get(pk=obj.pk) - if obj.status != original_obj.status: - if obj.status == models.DomainApplication.STARTED: - # No conditions - pass - elif obj.status == models.DomainApplication.SUBMITTED: - # This is an fsm in model which will throw an error if the - # transition condition is violated, so we roll back the - # status to what it was before the admin user changed it and - # let the fsm method set it. Same comment applies to - # transition method calls below. - obj.status = original_obj.status - obj.submit() - elif obj.status == models.DomainApplication.IN_REVIEW: - obj.status = original_obj.status - obj.in_review() - elif obj.status == models.DomainApplication.ACTION_NEEDED: - obj.status = original_obj.status - obj.action_needed() - elif obj.status == models.DomainApplication.APPROVED: - obj.status = original_obj.status - obj.approve() - elif obj.status == models.DomainApplication.WITHDRAWN: - obj.status = original_obj.status - obj.withdraw() - elif obj.status == models.DomainApplication.REJECTED: - obj.status = original_obj.status - obj.reject() - else: - logger.warning("Unknown status selected in django admin") + if obj.status != original_obj.status: + status_method_mapping = { + models.DomainApplication.STARTED: None, + models.DomainApplication.SUBMITTED: obj.submit, + models.DomainApplication.IN_REVIEW: obj.in_review, + models.DomainApplication.ACTION_NEEDED: obj.action_needed, + models.DomainApplication.APPROVED: obj.approve, + models.DomainApplication.WITHDRAWN: obj.withdraw, + models.DomainApplication.REJECTED: obj.reject, + models.DomainApplication.INELIGIBLE: obj.reject_with_prejudice, + } + selected_method = status_method_mapping.get(obj.status) + if selected_method is None: + logger.warning("Unknown status selected in django admin") + else: + # This is an fsm in model which will throw an error if the + # transition condition is violated, so we roll back the + # status to what it was before the admin user changed it and + # let the fsm method set it. + obj.status = original_obj.status + selected_method() - super().save_model(request, obj, form, change) + super().save_model(request, obj, form, change) + else: + # Clear the success message + messages.set_level(request, messages.ERROR) + + messages.error( + request, + "This action is not permitted for applications " + + "with a restricted creator.", + ) def get_readonly_fields(self, request, obj=None): + """Set the read-only state on form elements. + We have 2 conditions that determine which fields are read-only: + admin user permissions and the application creator's status, so + we'll use the baseline readonly_fields and extend it as needed. + """ + + readonly_fields = list(self.readonly_fields) + + # Check if the creator is restricted + if obj and obj.creator.status == models.User.RESTRICTED: + # For fields like CharField, IntegerField, etc., the widget used is + # straightforward and the readonly_fields list can control their behavior + readonly_fields.extend([field.name for field in self.model._meta.fields]) + # Add the multi-select fields to readonly_fields: + # Complex fields like ManyToManyField require special handling + readonly_fields.extend( + ["current_websites", "other_contacts", "alternative_domains"] + ) + if request.user.is_superuser: - # Superusers have full access, no fields are read-only - return [] + return readonly_fields else: - # Regular users can only view the specified fields - return self.readonly_fields + readonly_fields.extend([field for field in self.analyst_readonly_fields]) + return readonly_fields + + def display_restricted_warning(self, request, obj): + if obj and obj.creator.status == models.User.RESTRICTED: + messages.warning( + request, + "Cannot edit an application with a restricted creator.", + ) + + def change_view(self, request, object_id, form_url="", extra_context=None): + obj = self.get_object(request, object_id) + self.display_restricted_warning(request, obj) + return super().change_view(request, object_id, form_url, extra_context) admin.site.register(models.User, MyUserAdmin) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js new file mode 100644 index 000000000..3b9f19a49 --- /dev/null +++ b/src/registrar/assets/js/get-gov-admin.js @@ -0,0 +1,50 @@ +/** + * @file get-gov-admin.js includes custom code for the .gov registrar admin portal. + * + * Constants and helper functions are at the top. + * Event handlers are in the middle. + * Initialization (run-on-load) stuff goes at the bottom. + */ + +// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> +// Helper functions. +/** Either sets attribute target="_blank" to a given element, or removes it */ +function openInNewTab(el, removeAttribute = false){ + if(removeAttribute){ + el.setAttribute("target", "_blank"); + }else{ + el.removeAttribute("target", "_blank"); + } +}; + +// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> +// Event handlers. + +// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> +// Initialization code. + +/** An IIFE for pages in DjangoAdmin which may need custom JS implementation. + * Currently only appends target="_blank" to the domain_form object, + * but this can be expanded. +*/ +(function (){ + /* + On mouseover, appends target="_blank" on domain_form under the Domain page. + The reason for this is that the template has a form that contains multiple buttons. + The structure of that template complicates seperating those buttons + out of the form (while maintaining the same position on the page). + However, if we want to open one of those submit actions to a new tab - + such as the manage domain button - we need to dynamically append target. + As there is no built-in django method which handles this, we do it here. + */ + function prepareDjangoAdmin() { + let domainFormElement = document.getElementById("domain_form"); + let domainSubmitButton = document.getElementById("manageDomainSubmitButton"); + if(domainSubmitButton && domainFormElement){ + domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true)); + domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false)); + } + } + + prepareDjangoAdmin(); +})(); \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss index 905ab872c..4878235a9 100644 --- a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss +++ b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss @@ -432,3 +432,21 @@ abbr[title] { height: units('mobile'); } } + +// Fixes some font size disparities with the Figma +// for usa-alert alert elements +.usa-alert { + .usa-alert__heading.larger-font-sizing { + font-size: units(3); + } +} + +// The icon was off center for some reason +// Fixes that issue +@media (min-width: 64em){ + .usa-alert--warning{ + .usa-alert__body::before { + left: 1rem !important; + } + } +} diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 8b53fa82a..e272e6622 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -251,8 +251,7 @@ AWS_MAX_ATTEMPTS = 3 BOTO_CONFIG = Config(retries={"mode": AWS_RETRY_MODE, "max_attempts": AWS_MAX_ATTEMPTS}) # email address to use for various automated correspondence -# TODO: pick something sensible here -DEFAULT_FROM_EMAIL = "registrar@get.gov" +DEFAULT_FROM_EMAIL = "help@get.gov " # connect to an (external) SMTP server for sending email EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index 0b1b8926d..f37474e71 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -63,7 +63,7 @@ class UserFixture: "last_name": "Adkinson", }, { - "username": "bb21f687-c773-4df3-9243-111cfd4c0be4", + "username": "2bf518c2-485a-4c42-ab1a-f5a8b0a08484", "first_name": "Paul", "last_name": "Kuykendall", }, @@ -72,6 +72,16 @@ class UserFixture: "first_name": "Rebecca", "last_name": "Hsieh", }, + { + "username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52", + "first_name": "David", + "last_name": "Kennedy", + }, + { + "username": "f14433d8-f0e9-41bf-9c72-b99b110e665d", + "first_name": "Nicolle", + "last_name": "LeClair", + }, ] STAFF = [ @@ -86,6 +96,12 @@ class UserFixture: "first_name": "Alysia-Analyst", "last_name": "Alysia-Analyst", }, + { + "username": "91a9b97c-bd0a-458d-9823-babfde7ebf44", + "first_name": "Katherine-Analyst", + "last_name": "Osos-Analyst", + "email": "kosos@truss.works", + }, { "username": "2cc0cde8-8313-4a50-99d8-5882e71443e8", "first_name": "Zander-Analyst", @@ -101,6 +117,23 @@ class UserFixture: "first_name": "Rebecca-Analyst", "last_name": "Hsieh-Analyst", }, + { + "username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c", + "first_name": "David-Analyst", + "last_name": "Kennedy-Analyst", + }, + { + "username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47", + "first_name": "Gaby-Analyst", + "last_name": "DiSarli-Analyst", + "email": "gaby@truss.works", + }, + { + "username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3", + "first_name": "Nicolle-Analyst", + "last_name": "LeClair", + "email": "nicolle.leclair@ecstech.com", + }, ] STAFF_PERMISSIONS = [ @@ -248,6 +281,14 @@ class DomainApplicationFixture: "status": "withdrawn", "organization_name": "Example - Withdrawn", }, + { + "status": "action needed", + "organization_name": "Example - Action Needed", + }, + { + "status": "rejected", + "organization_name": "Example - Rejected", + }, ] @classmethod diff --git a/src/registrar/management/commands/load.py b/src/registrar/management/commands/load.py index e48d3f211..69e7e9ec8 100644 --- a/src/registrar/management/commands/load.py +++ b/src/registrar/management/commands/load.py @@ -2,6 +2,7 @@ import logging from django.core.management.base import BaseCommand from auditlog.context import disable_auditlog # type: ignore +from django.conf import settings from registrar.fixtures import UserFixture, DomainApplicationFixture, DomainFixture @@ -12,8 +13,11 @@ class Command(BaseCommand): def handle(self, *args, **options): # django-auditlog has some bugs with fixtures # https://github.com/jazzband/django-auditlog/issues/17 - with disable_auditlog(): - UserFixture.load() - DomainApplicationFixture.load() - DomainFixture.load() - logger.info("All fixtures loaded.") + if settings.DEBUG: + with disable_auditlog(): + UserFixture.load() + DomainApplicationFixture.load() + DomainFixture.load() + logger.info("All fixtures loaded.") + else: + logger.warn("Refusing to load fixture data in a non DEBUG env") diff --git a/src/registrar/migrations/0029_user_status_alter_domainapplication_status.py b/src/registrar/migrations/0029_user_status_alter_domainapplication_status.py new file mode 100644 index 000000000..504358665 --- /dev/null +++ b/src/registrar/migrations/0029_user_status_alter_domainapplication_status.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.1 on 2023-08-18 16:59 + +from django.db import migrations, models +import django_fsm + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0028_alter_domainapplication_status"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="status", + field=models.CharField( + blank=True, + choices=[("ineligible", "ineligible")], + default=None, + max_length=10, + null=True, + ), + ), + migrations.AlterField( + model_name="domainapplication", + name="status", + field=django_fsm.FSMField( + choices=[ + ("started", "started"), + ("submitted", "submitted"), + ("in review", "in review"), + ("action needed", "action needed"), + ("approved", "approved"), + ("withdrawn", "withdrawn"), + ("rejected", "rejected"), + ("ineligible", "ineligible"), + ], + default="started", + max_length=50, + ), + ), + ] diff --git a/src/registrar/migrations/0030_alter_user_status.py b/src/registrar/migrations/0030_alter_user_status.py new file mode 100644 index 000000000..7dd27bfa4 --- /dev/null +++ b/src/registrar/migrations/0030_alter_user_status.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.1 on 2023-08-29 17:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0029_user_status_alter_domainapplication_status"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="status", + field=models.CharField( + blank=True, + choices=[("restricted", "restricted")], + default=None, + max_length=10, + null=True, + ), + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index a1f2eb67c..9bc3bf476 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -721,6 +721,9 @@ class Domain(TimeStampedModel, DomainHelper): help_text="Very basic info about the lifecycle of this domain object", ) + def isActive(self): + return self.state == Domain.State.CREATED + # ForeignKey on UserDomainRole creates a "permissions" member for # all of the user-roles that are in place for this domain diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 67f1ee5d9..b1230b703 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -26,6 +26,7 @@ class DomainApplication(TimeStampedModel): APPROVED = "approved" WITHDRAWN = "withdrawn" REJECTED = "rejected" + INELIGIBLE = "ineligible" STATUS_CHOICES = [ (STARTED, STARTED), (SUBMITTED, SUBMITTED), @@ -34,6 +35,7 @@ class DomainApplication(TimeStampedModel): (APPROVED, APPROVED), (WITHDRAWN, WITHDRAWN), (REJECTED, REJECTED), + (INELIGIBLE, INELIGIBLE), ] class StateTerritoryChoices(models.TextChoices): @@ -554,7 +556,9 @@ class DomainApplication(TimeStampedModel): ) @transition( - field="status", source=[SUBMITTED, IN_REVIEW, REJECTED], target=APPROVED + field="status", + source=[SUBMITTED, IN_REVIEW, REJECTED, INELIGIBLE], + target=APPROVED, ) def approve(self): """Approve an application that has been submitted. @@ -590,6 +594,11 @@ class DomainApplication(TimeStampedModel): @transition(field="status", source=[SUBMITTED, IN_REVIEW], target=WITHDRAWN) def withdraw(self): """Withdraw an application that has been submitted.""" + self._send_status_update_email( + "withdraw", + "emails/domain_request_withdrawn.txt", + "emails/domain_request_withdrawn_subject.txt", + ) @transition(field="status", source=[IN_REVIEW, APPROVED], target=REJECTED) def reject(self): @@ -603,6 +612,17 @@ class DomainApplication(TimeStampedModel): "emails/status_change_rejected_subject.txt", ) + @transition(field="status", source=[IN_REVIEW, APPROVED], target=INELIGIBLE) + def reject_with_prejudice(self): + """The applicant is a bad actor, reject with prejudice. + + No email As a side effect, but we block the applicant from editing + any existing domains/applications and from submitting new aplications. + We do this by setting an ineligible status on the user, which the + permissions classes test against""" + + self.creator.restrict_user() + # ## Form policies ### # # These methods control what questions need to be answered by applicants diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 4cd8b6c90..5cf1dd71f 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -17,6 +17,18 @@ class User(AbstractUser): but can be customized later. """ + # #### Constants for choice fields #### + RESTRICTED = "restricted" + STATUS_CHOICES = ((RESTRICTED, RESTRICTED),) + + status = models.CharField( + max_length=10, + choices=STATUS_CHOICES, + default=None, # Set the default value to None + null=True, # Allow the field to be null + blank=True, # Allow the field to be blank + ) + domains = models.ManyToManyField( "registrar.Domain", through="registrar.UserDomainRole", @@ -39,6 +51,17 @@ class User(AbstractUser): else: return self.username + def restrict_user(self): + self.status = self.RESTRICTED + self.save() + + def unrestrict_user(self): + self.status = None + self.save() + + def is_restricted(self): + return self.status == self.RESTRICTED + def first_login(self): """Callback when the user is authenticated for the very first time. diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 62c5873f0..4e7768ef4 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -1,8 +1,6 @@ import logging -from django.conf import settings -from django.core.management import call_command -from django.db.models.signals import post_save, post_migrate +from django.db.models.signals import post_save from django.dispatch import receiver from .models import User, Contact @@ -55,13 +53,3 @@ def handle_profile(sender, instance, **kwargs): "There are multiple Contacts with the same email address." f" Picking #{contacts[0].id} for User #{instance.id}." ) - - -@receiver(post_migrate) -def handle_loaddata(**kwargs): - """Attempt to load test fixtures when in DEBUG mode.""" - if settings.DEBUG: - try: - call_command("load") - except Exception as e: - logger.warning(e) diff --git a/src/registrar/templates/admin/change_form.html b/src/registrar/templates/admin/change_form.html new file mode 100644 index 000000000..e0f9ae1a4 --- /dev/null +++ b/src/registrar/templates/admin/change_form.html @@ -0,0 +1,12 @@ +{% extends "admin/change_form.html" %} + +{% comment %} Replace the Django ul markup with a div. We'll edit the child markup accordingly in change_form_object_tools {% endcomment %} +{% block object-tools %} +{% if change and not is_popup %} +
+ {% block object-tools-items %} + {{ block.super }} + {% endblock %} +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/admin/change_form_object_tools.html b/src/registrar/templates/admin/change_form_object_tools.html new file mode 100644 index 000000000..28c655bbc --- /dev/null +++ b/src/registrar/templates/admin/change_form_object_tools.html @@ -0,0 +1,20 @@ +{% load i18n admin_urls %} + +{% comment %} Replace li with p for more semantic HTML if we have a single child {% endcomment %} +{% block object-tools-items %} + {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} + {% if has_absolute_url %} + + {% else %} +

+ {% translate "History" %} +

+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/admin/change_list.html b/src/registrar/templates/admin/change_list.html index 1026e7d60..4a58a4b7e 100644 --- a/src/registrar/templates/admin/change_list.html +++ b/src/registrar/templates/admin/change_list.html @@ -24,3 +24,12 @@ {% endif %} {% endblock %} + +{% comment %} Replace the Django ul markup with a div. We'll replace the li with a p in change_list_object_tools {% endcomment %} +{% block object-tools %} +
+ {% block object-tools-items %} + {{ block.super }} + {% endblock %} +
+{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/admin/change_list_object_tools.html b/src/registrar/templates/admin/change_list_object_tools.html new file mode 100644 index 000000000..9a046b4bb --- /dev/null +++ b/src/registrar/templates/admin/change_list_object_tools.html @@ -0,0 +1,13 @@ +{% load i18n admin_urls %} + +{% comment %} Replace li with p for more semantic HTML {% endcomment %} +{% block object-tools-items %} + {% if has_add_permission %} +

+ {% url cl.opts|admin_urlname:'add' as add_url %} + + {% blocktranslate with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktranslate %} + +

+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/admin/change_list_results.html b/src/registrar/templates/admin/change_list_results.html index 9ee3f9f59..4ced73eb3 100644 --- a/src/registrar/templates/admin/change_list_results.html +++ b/src/registrar/templates/admin/change_list_results.html @@ -17,7 +17,7 @@ Load our custom filters to extract info from the django generated markup. -{% if results.0.form %} +{% if results.0|contains_checkbox %} {# .gov - hardcode the select all checkbox #}
@@ -60,12 +60,11 @@ Load our custom filters to extract info from the django generated markup. {{ result.form.non_field_errors }} {% endif %} - {% with result_value=result.0|extract_value %} {% with result_label=result.1|extract_a_text %} - - + + {% endwith %} {% endwith %} diff --git a/src/registrar/templates/application_dotgov_domain.html b/src/registrar/templates/application_dotgov_domain.html index b2ee6cab6..fd6ce9604 100644 --- a/src/registrar/templates/application_dotgov_domain.html +++ b/src/registrar/templates/application_dotgov_domain.html @@ -51,7 +51,7 @@ {% with attr_aria_describedby="domain_instructions domain_instructions2" %} {# attr_validate / validate="domain" invokes code in get-gov.js #} - {% with www_gov=True attr_validate="domain" add_label_class="usa-sr-only" %} + {% with append_gov=True attr_validate="domain" add_label_class="usa-sr-only" %} {% input_with_errors forms.0.requested_domain %} {% endwith %} {% endwith %} @@ -75,7 +75,7 @@ {% with attr_aria_describedby="alt_domain_instructions" %} {# attr_validate / validate="domain" invokes code in get-gov.js #} {# attr_auto_validate likewise triggers behavior in get-gov.js #} - {% with www_gov=True attr_validate="domain" attr_auto_validate=True %} + {% with append_gov=True attr_validate="domain" attr_auto_validate=True %} {% for form in forms.1 %} {% input_with_errors form.alternative_domain %} {% endfor %} diff --git a/src/registrar/templates/application_status.html b/src/registrar/templates/application_status.html index 99f6a1d4c..2d59a32eb 100644 --- a/src/registrar/templates/application_status.html +++ b/src/registrar/templates/application_status.html @@ -23,6 +23,7 @@ {% elif domainapplication.status == 'in review' %} In Review {% elif domainapplication.status == 'rejected' %} Rejected {% elif domainapplication.status == 'submitted' %} Submitted + {% elif domainapplication.status == 'ineligible' %} Ineligible {% else %}ERROR Please contact technical support/dev {% endif %}

@@ -83,6 +84,14 @@ {% include "includes/summary_item.html" with title='Current website for your organization' value=domainapplication.current_websites.all list='true' heading_level=heading_level %} {% endif %} + {% if domainapplication.requested_domain %} + {% include "includes/summary_item.html" with title='.gov domain' value=domainapplication.requested_domain heading_level=heading_level %} + {% endif %} + + {% if domainapplication.alternative_domains.all %} + {% include "includes/summary_item.html" with title='Alternative domains' value=domainapplication.alternative_domains.all list='true' heading_level=heading_level %} + {% endif %} + {% if domainapplication.purpose %} {% include "includes/summary_item.html" with title='Purpose of your domain' value=domainapplication.purpose heading_level=heading_level %} {% endif %} diff --git a/src/registrar/templates/base.html b/src/registrar/templates/base.html index 977366e47..72c30f323 100644 --- a/src/registrar/templates/base.html +++ b/src/registrar/templates/base.html @@ -149,12 +149,12 @@ -