Merge branch 'main' of https://github.com/cisagov/manage.get.gov into es/3175-email-updates

This commit is contained in:
Erin Song 2025-01-14 10:38:01 -08:00
commit bff19e3a28
No known key found for this signature in database
19 changed files with 678 additions and 53 deletions

View file

@ -103,3 +103,31 @@ response = registry._client.transport.receive()
``` ```
This is helpful for debugging situations where epplib is not correctly or fully parsing the XML returned from the registry. This is helpful for debugging situations where epplib is not correctly or fully parsing the XML returned from the registry.
### Adding in a expiring soon domain
The below scenario is if you are NOT in org model mode (`organization_feature` waffle flag is off).
1. Go to the `staging` sandbox and to `/admin`
2. Go to Domains and find a domain that is actually expired by sorting the Expiration Date column
3. Click into the domain to check the expiration date
4. Click into Manage Domain to double check the expiration date as well
5. Now hold onto that domain name, and save it for the command below
6. In a terminal, run these commands:
```
cf ssh getgov-<your-intials>
/tmp/lifecycle/shell
./manage.py shell
from registrar.models import Domain, DomainInvitation
from registrar.models import User
user = User.objects.filter(first_name="<your-first-name>")
domain = Domain.objects.get_or_create(name="<that-domain-here>")
```
7. Go back to `/admin` and create Domain Information for that domain you just added in via the terminal
8. Go to Domain to find it
9. Click Manage Domain
10. Add yourself as domain manager
11. Go to the Registrar page and you should now see the expiring domain
If you want to be in the org model mode, turn the `organization_feature` waffle flag on, and add that domain via Django Admin to a portfolio to be able to view it.

View file

@ -345,6 +345,11 @@ urlpatterns = [
views.DomainSecurityEmailView.as_view(), views.DomainSecurityEmailView.as_view(),
name="domain-security-email", name="domain-security-email",
), ),
path(
"domain/<int:pk>/renewal",
views.DomainRenewalView.as_view(),
name="domain-renewal",
),
path( path(
"domain/<int:pk>/users/add", "domain/<int:pk>/users/add",
views.DomainAddUserView.as_view(), views.DomainAddUserView.as_view(),

View file

@ -10,6 +10,7 @@ from .domain import (
DomainDsdataFormset, DomainDsdataFormset,
DomainDsdataForm, DomainDsdataForm,
DomainSuborganizationForm, DomainSuborganizationForm,
DomainRenewalForm,
) )
from .portfolio import ( from .portfolio import (
PortfolioOrgAddressForm, PortfolioOrgAddressForm,

View file

@ -661,3 +661,15 @@ DomainDsdataFormset = formset_factory(
extra=0, extra=0,
can_delete=True, can_delete=True,
) )
class DomainRenewalForm(forms.Form):
"""Form making sure domain renewal ack is checked"""
is_policy_acknowledged = forms.BooleanField(
required=True,
label="I have read and agree to the requirements for operating a .gov domain.",
error_messages={
"required": "Check the box if you read and agree to the requirements for operating a .gov domain."
},
)

View file

@ -5,6 +5,7 @@ import logging
from django.core.management import BaseCommand, CommandError from django.core.management import BaseCommand, CommandError
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User
from registrar.models.utility.generic_helper import normalize_string
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -21,10 +22,21 @@ class Command(BaseCommand):
self.failed_portfolios = set() self.failed_portfolios = set()
def add_arguments(self, parser): def add_arguments(self, parser):
"""Add three arguments: """Add command line arguments to create federal portfolios.
1. agency_name => the value of FederalAgency.agency
2. --parse_requests => if true, adds the given portfolio to each related DomainRequest Required (mutually exclusive) arguments:
3. --parse_domains => if true, adds the given portfolio to each related DomainInformation --agency_name: Name of a specific FederalAgency to create a portfolio for
--branch: Federal branch to process ("executive", "legislative", or "judicial").
Creates portfolios for all FederalAgencies in that branch.
Required (at least one):
--parse_requests: Add the created portfolio(s) to related DomainRequest records
--parse_domains: Add the created portfolio(s) to related DomainInformation records
Note: You can use both --parse_requests and --parse_domains together
Optional (mutually exclusive with parse options):
--both: Shorthand for using both --parse_requests and --parse_domains
Cannot be used with --parse_requests or --parse_domains
""" """
group = parser.add_mutually_exclusive_group(required=True) group = parser.add_mutually_exclusive_group(required=True)
group.add_argument( group.add_argument(
@ -78,12 +90,14 @@ class Command(BaseCommand):
else: else:
raise CommandError(f"Cannot find '{branch}' federal agencies in our database.") raise CommandError(f"Cannot find '{branch}' federal agencies in our database.")
portfolios = []
for federal_agency in agencies: for federal_agency in agencies:
message = f"Processing federal agency '{federal_agency.agency}'..." message = f"Processing federal agency '{federal_agency.agency}'..."
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
try: try:
# C901 'Command.handle' is too complex (12) # C901 'Command.handle' is too complex (12)
self.handle_populate_portfolio(federal_agency, parse_domains, parse_requests, both) portfolio = self.handle_populate_portfolio(federal_agency, parse_domains, parse_requests, both)
portfolios.append(portfolio)
except Exception as exec: except Exception as exec:
self.failed_portfolios.add(federal_agency) self.failed_portfolios.add(federal_agency)
logger.error(exec) logger.error(exec)
@ -99,9 +113,65 @@ class Command(BaseCommand):
display_as_str=True, display_as_str=True,
) )
# POST PROCESSING STEP: Remove the federal agency if it matches the portfolio name.
# We only do this for started domain requests.
if parse_requests or both:
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
prompt_message="This action will update domain requests even if they aren't on a portfolio.",
prompt_title=(
"POST PROCESS STEP: Do you want to clear federal agency on (related) started domain requests?"
),
verify_message=None,
)
self.post_process_started_domain_requests(agencies, portfolios)
def post_process_started_domain_requests(self, agencies, portfolios):
"""
Removes duplicate organization data by clearing federal_agency when it matches the portfolio name.
Only processes domain requests in STARTED status.
"""
message = "Removing duplicate portfolio and federal_agency values from domain requests..."
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
# For each request, clear the federal agency under these conditions:
# 1. A portfolio *already exists* with the same name as the federal agency.
# 2. Said portfolio (or portfolios) are only the ones specified at the start of the script.
# 3. The domain request is in status "started".
# Note: Both names are normalized so excess spaces are stripped and the string is lowercased.
domain_requests_to_update = DomainRequest.objects.filter(
federal_agency__in=agencies,
federal_agency__agency__isnull=False,
status=DomainRequest.DomainRequestStatus.STARTED,
organization_name__isnull=False,
)
portfolio_set = {normalize_string(portfolio.organization_name) for portfolio in portfolios if portfolio}
# Update the request, assuming the given agency name matches the portfolio name
updated_requests = []
for req in domain_requests_to_update:
agency_name = normalize_string(req.federal_agency.agency)
if agency_name in portfolio_set:
req.federal_agency = None
updated_requests.append(req)
# Execute the update and Log the results
if TerminalHelper.prompt_for_execution(
system_exit_on_terminate=False,
prompt_message=(
f"{len(domain_requests_to_update)} domain requests will be updated. "
f"These records will be changed: {[str(req) for req in updated_requests]}"
),
prompt_title="Do you wish to commit this update to the database?",
):
DomainRequest.objects.bulk_update(updated_requests, ["federal_agency"])
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, "Action completed successfully.")
def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both): def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both):
"""Attempts to create a portfolio. If successful, this function will """Attempts to create a portfolio. If successful, this function will
also create new suborganizations""" also create new suborganizations.
Returns the portfolio for the given federal_agency.
"""
portfolio, created = self.create_portfolio(federal_agency) portfolio, created = self.create_portfolio(federal_agency)
if created: if created:
self.create_suborganizations(portfolio, federal_agency) self.create_suborganizations(portfolio, federal_agency)
@ -111,6 +181,8 @@ class Command(BaseCommand):
if parse_requests or both: if parse_requests or both:
self.handle_portfolio_requests(portfolio, federal_agency) self.handle_portfolio_requests(portfolio, federal_agency)
return portfolio
def create_portfolio(self, federal_agency): def create_portfolio(self, federal_agency):
"""Creates a portfolio if it doesn't presently exist. """Creates a portfolio if it doesn't presently exist.
Returns portfolio, created.""" Returns portfolio, created."""
@ -172,7 +244,7 @@ class Command(BaseCommand):
return return
# Check for existing suborgs on the current portfolio # Check for existing suborgs on the current portfolio
existing_suborgs = Suborganization.objects.filter(name__in=org_names) existing_suborgs = Suborganization.objects.filter(name__in=org_names, name__isnull=False)
if existing_suborgs.exists(): if existing_suborgs.exists():
message = f"Some suborganizations already exist for portfolio '{portfolio}'." message = f"Some suborganizations already exist for portfolio '{portfolio}'."
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, message) TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, message)
@ -180,9 +252,7 @@ class Command(BaseCommand):
# Create new suborgs, as long as they don't exist in the db already # Create new suborgs, as long as they don't exist in the db already
new_suborgs = [] new_suborgs = []
for name in org_names - set(existing_suborgs.values_list("name", flat=True)): for name in org_names - set(existing_suborgs.values_list("name", flat=True)):
# Stored in variables due to linter wanting type information here. if normalize_string(name) == normalize_string(portfolio.organization_name):
portfolio_name: str = portfolio.organization_name if portfolio.organization_name is not None else ""
if name is not None and name.lower() == portfolio_name.lower():
# You can use this to populate location information, when this occurs. # You can use this to populate location information, when this occurs.
# However, this isn't needed for now so we can skip it. # However, this isn't needed for now so we can skip it.
message = ( message = (
@ -229,12 +299,30 @@ class Command(BaseCommand):
# Get all suborg information and store it in a dict to avoid doing a db call # Get all suborg information and store it in a dict to avoid doing a db call
suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name") suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name")
for domain_request in domain_requests: for domain_request in domain_requests:
# Set the portfolio
domain_request.portfolio = portfolio domain_request.portfolio = portfolio
if domain_request.organization_name in suborgs:
domain_request.sub_organization = suborgs.get(domain_request.organization_name) # Set suborg info
domain_request.sub_organization = suborgs.get(domain_request.organization_name, None)
if domain_request.sub_organization is None:
domain_request.requested_suborganization = normalize_string(
domain_request.organization_name, lowercase=False
)
domain_request.suborganization_city = normalize_string(domain_request.city, lowercase=False)
domain_request.suborganization_state_territory = domain_request.state_territory
self.updated_portfolios.add(portfolio) self.updated_portfolios.add(portfolio)
DomainRequest.objects.bulk_update(domain_requests, ["portfolio", "sub_organization"]) DomainRequest.objects.bulk_update(
domain_requests,
[
"portfolio",
"sub_organization",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
],
)
message = f"Added portfolio '{portfolio}' to {len(domain_requests)} domain requests." message = f"Added portfolio '{portfolio}' to {len(domain_requests)} domain requests."
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message) TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
@ -242,6 +330,8 @@ class Command(BaseCommand):
""" """
Associate portfolio with domains for a federal agency. Associate portfolio with domains for a federal agency.
Updates all relevant domain information records. Updates all relevant domain information records.
Returns a queryset of DomainInformation objects, or None if nothing changed.
""" """
domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency, portfolio__isnull=True) domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency, portfolio__isnull=True)
if not domain_infos.exists(): if not domain_infos.exists():
@ -257,8 +347,7 @@ class Command(BaseCommand):
suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name") suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name")
for domain_info in domain_infos: for domain_info in domain_infos:
domain_info.portfolio = portfolio domain_info.portfolio = portfolio
if domain_info.organization_name in suborgs: domain_info.sub_organization = suborgs.get(domain_info.organization_name, None)
domain_info.sub_organization = suborgs.get(domain_info.organization_name)
DomainInformation.objects.bulk_update(domain_infos, ["portfolio", "sub_organization"]) DomainInformation.objects.bulk_update(domain_infos, ["portfolio", "sub_organization"])
message = f"Added portfolio '{portfolio}' to {len(domain_infos)} domains." message = f"Added portfolio '{portfolio}' to {len(domain_infos)} domains."

View file

@ -326,9 +326,8 @@ class Domain(TimeStampedModel, DomainHelper):
exp_date = self.registry_expiration_date exp_date = self.registry_expiration_date
except KeyError: except KeyError:
# if no expiration date from registry, set it to today # if no expiration date from registry, set it to today
logger.warning("current expiration date not set; setting to today") logger.warning("current expiration date not set; setting to today", exc_info=True)
exp_date = date.today() exp_date = date.today()
# create RenewDomain request # create RenewDomain request
request = commands.RenewDomain(name=self.name, cur_exp_date=exp_date, period=epp.Period(length, unit)) request = commands.RenewDomain(name=self.name, cur_exp_date=exp_date, period=epp.Period(length, unit))
@ -338,13 +337,14 @@ class Domain(TimeStampedModel, DomainHelper):
self._cache["ex_date"] = registry.send(request, cleaned=True).res_data[0].ex_date self._cache["ex_date"] = registry.send(request, cleaned=True).res_data[0].ex_date
self.expiration_date = self._cache["ex_date"] self.expiration_date = self._cache["ex_date"]
self.save() self.save()
except RegistryError as err: except RegistryError as err:
# if registry error occurs, log the error, and raise it as well # if registry error occurs, log the error, and raise it as well
logger.error(f"registry error renewing domain: {err}") logger.error(f"Registry error renewing domain '{self.name}': {err}")
raise (err) raise (err)
except Exception as e: except Exception as e:
# exception raised during the save to registrar # exception raised during the save to registrar
logger.error(f"error updating expiration date in registrar: {e}") logger.error(f"Error updating expiration date for domain '{self.name}' in registrar: {e}")
raise (e) raise (e)
@Cache @Cache
@ -1575,7 +1575,7 @@ class Domain(TimeStampedModel, DomainHelper):
logger.info("Changing to DNS_NEEDED state") logger.info("Changing to DNS_NEEDED state")
logger.info("able to transition to DNS_NEEDED state") logger.info("able to transition to DNS_NEEDED state")
def get_state_help_text(self) -> str: def get_state_help_text(self, request=None) -> str:
"""Returns a str containing additional information about a given state. """Returns a str containing additional information about a given state.
Returns custom content for when the domain itself is expired.""" Returns custom content for when the domain itself is expired."""
@ -1585,6 +1585,8 @@ class Domain(TimeStampedModel, DomainHelper):
help_text = ( help_text = (
"This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov." "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov."
) )
elif flag_is_active(request, "domain_renewal") and self.is_expiring():
help_text = "This domain will expire soon. Contact one of the listed domain managers to renew the domain."
else: else:
help_text = Domain.State.get_help_text(self.state) help_text = Domain.State.get_help_text(self.state)

View file

@ -343,3 +343,13 @@ def value_of_attribute(obj, attribute_name: str):
if callable(value): if callable(value):
value = value() value = value()
return value return value
def normalize_string(string_to_normalize, lowercase=True):
"""Normalizes a given string. Returns a string without extra spaces, in all lowercase."""
if not isinstance(string_to_normalize, str):
logger.error(f"normalize_string => {string_to_normalize} is not type str.")
return string_to_normalize
new_string = " ".join(string_to_normalize.split())
return new_string.lower() if lowercase else new_string

View file

@ -1,5 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load static url_helpers %}
{% block title %}{{ domain.name }} | {% endblock %} {% block title %}{{ domain.name }} | {% endblock %}
@ -53,8 +55,11 @@
{% endif %} {% endif %}
{% block domain_content %} {% block domain_content %}
{% if request.path|endswith:"renewal"%}
<h1>Renew {{domain.name}} </h1>
{%else%}
<h1 class="break-word">Domain Overview</h1> <h1 class="break-word">Domain Overview</h1>
{% endif%}
{% endblock %} {# domain_content #} {% endblock %} {# domain_content #}
{% endif %} {% endif %}
@ -62,4 +67,4 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {# content #} {% endblock %} {# content #}

View file

@ -49,10 +49,18 @@
</span> </span>
{% if domain.get_state_help_text %} {% if domain.get_state_help_text %}
<div class="padding-top-1 text-primary-darker"> <div class="padding-top-1 text-primary-darker">
{% if has_domain_renewal_flag and domain.is_expiring and is_domain_manager %} {% if has_domain_renewal_flag and domain.is_expired and is_domain_manager %}
This domain will expire soon. <a href="/not-available-yet">Renew to maintain access.</a> This domain has expired, but it is still online.
{% url 'domain-renewal' pk=domain.id as url %}
<a href="{{ url }}">Renew to maintain access.</a>
{% elif has_domain_renewal_flag and domain.is_expiring and is_domain_manager %}
This domain will expire soon.
{% url 'domain-renewal' pk=domain.id as url %}
<a href="{{ url }}">Renew to maintain access.</a>
{% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %} {% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %}
This domain will expire soon. Contact one of the listed domain managers to renew the domain. This domain will expire soon. Contact one of the listed domain managers to renew the domain.
{% elif has_domain_renewal_flag and domain.is_expired and is_portfolio_user %}
This domain has expired, but it is still online. Contact one of the listed domain managers to renew the domain.
{% else %} {% else %}
{{ domain.get_state_help_text }} {{ domain.get_state_help_text }}
{% endif %} {% endif %}

View file

@ -0,0 +1,140 @@
{% extends "domain_base.html" %}
{% load static url_helpers %}
{% load custom_filters %}
{% block domain_content %}
{% block breadcrumb %}
<!-- Banner for if_policy_acknowledged -->
{% if form.is_policy_acknowledged.errors %}
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2">
<div class="usa-alert__body">
{% for error in form.is_policy_acknowledged.errors %}
<p class="usa-alert__text">{{ error }}</p>
{% endfor %}
</div>
</div>
{% endif %}
{% if portfolio %}
<!-- Navigation breadcrumbs -->
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{domain.name}}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Renewal Form</span>
</li>
</ol>
</nav>
{% endif %}
{% endblock breadcrumb %}
{{ block.super }}
<div class="margin-top-4 tablet:grid-col-10">
<h2 class="text-bold text-primary-dark domain-name-wrap">Confirm the following information for accuracy</h2>
<p>Review these details below. We <a href="https://get.gov/domains/requirements/#what-.gov-domain-registrants-must-do" class="usa-link">
require</a> that you maintain accurate information for the domain.
The details you provide will only be used to support the administration of .gov and won't be made public.
</p>
<p>If you would like to retire your domain instead, please <a href="https://get.gov/contact/" class="usa-link">
contact us</a>. </p>
<p><em>Required fields are marked with an asterisk (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
</p>
{% url 'user-profile' as url %}
{% include "includes/summary_item.html" with title='Your contact information' value=request.user edit_link=url editable=is_editable contact='true' %}
{% if analyst_action != 'edit' or analyst_action_location != domain.pk %}
{% if is_portfolio_user and not is_domain_manager %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body">
<p class="usa-alert__text ">
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
</p>
</div>
</div>
{% endif %}
{% endif %}
{% url 'domain-security-email' pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%}
{% include "includes/summary_item.html" with title='Security email' value=security_email custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
{% else %}
{% include "includes/summary_item.html" with title='Security email' value='None provided' custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
{% endif %}
{% url 'domain-users' pk=domain.id as url %}
{% if portfolio %}
{% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %}
{% else %}
{% include "includes/summary_item.html" with title='Domain managers' list=True users=True value=domain.permissions.all edit_link=url editable=is_editable %}
{% endif %}
<div class="border-top-1px border-primary-dark padding-top-1 margin-top-3 margin-bottom-2">
<fieldset class="usa-fieldset">
<legend>
<h3 class="summary-item__title
font-sans-md
text-primary-dark
text-semibold
margin-top-0
margin-bottom-05
padding-right-1">
Acknowledgement of .gov domain requirements </h3>
</legend>
<form method="post" action="{% url 'domain-renewal' pk=domain.id %}">
{% csrf_token %}
<div class="usa-checkbox">
{% if form.is_policy_acknowledged.errors %}
{% for error in form.is_policy_acknowledged.errors %}
<div class="usa-error-message display-flex" role="alert">
<svg class="usa-icon usa-icon--large" focusable="true" role="img" aria-label="Error">
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
</svg>
<span class="margin-left-05">{{ error }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<input type="hidden" name="is_policy_acknowledged" value="False">
<input
class="usa-checkbox__input"
id="renewal-checkbox"
type="checkbox"
name="is_policy_acknowledged"
value="True"
{% if form.is_policy_acknowledged.value %}checked{% endif %}
>
<label class="usa-checkbox__label" for="renewal-checkbox">
I read and agree to the
<a href="https://get.gov/domains/requirements/" class="usa-link">
requirements for operating a .gov domain
</a>.
<abbr class="usa-hint usa-hint--required" title="required">*</abbr>
</label>
</div>
<button
type="submit"
name="submit_button"
value="next"
class="usa-button margin-top-3"
> Submit
</button>
</form>
</fieldset>
</div> <!-- End of the acknowledgement section div -->
</div>
{% endblock %} {# domain_content #}

View file

@ -80,7 +80,16 @@
{% include "includes/domain_sidenav_item.html" with item_text="Domain managers" %} {% include "includes/domain_sidenav_item.html" with item_text="Domain managers" %}
{% endwith %} {% endwith %}
{% if has_domain_renewal_flag and is_domain_manager%}
{% if domain.is_expiring or domain.is_expired %}
{% with url_name="domain-renewal" %}
{% include "includes/domain_sidenav_item.html" with item_text="Renewal form" %}
{% endwith %}
{% endif %}
{% endif %}
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>
</div> </div>

View file

@ -172,7 +172,7 @@
>Deleted</label >Deleted</label
> >
</div> </div>
{% if has_domain_renewal_flag and num_expiring_domains > 0 %} {% if has_domain_renewal_flag %}
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
class="usa-checkbox__input" class="usa-checkbox__input"

View file

@ -127,15 +127,15 @@
</ul> </ul>
{% endif %} {% endif %}
{% else %} {% else %}
<p class="margin-top-0 margin-bottom-0"> {% if custom_text_for_value_none %}
<p class="margin-top-0 text-base-dark">{{ custom_text_for_value_none }}</p>
{% endif %}
{% if value %} {% if value %}
{{ value }} {{ value }}
{% elif custom_text_for_value_none %} {% endif %}
{{ custom_text_for_value_none }} {% if not value %}
{% else %}
None None
{% endif %} {% endif %}
</p>
{% endif %} {% endif %}
</div> </div>

View file

@ -200,6 +200,7 @@ def is_domain_subpage(path):
"domain-users-add", "domain-users-add",
"domain-request-delete", "domain-request-delete",
"domain-user-delete", "domain-user-delete",
"domain-renewal",
"invitation-cancel", "invitation-cancel",
] ]
return get_url_name(path) in url_names return get_url_name(path) in url_names

View file

@ -1039,6 +1039,8 @@ def completed_domain_request( # noqa
federal_agency=None, federal_agency=None,
federal_type=None, federal_type=None,
action_needed_reason=None, action_needed_reason=None,
city=None,
state_territory=None,
portfolio=None, portfolio=None,
organization_name=None, organization_name=None,
sub_organization=None, sub_organization=None,
@ -1081,7 +1083,7 @@ def completed_domain_request( # noqa
organization_name=organization_name if organization_name else "Testorg", organization_name=organization_name if organization_name else "Testorg",
address_line1="address 1", address_line1="address 1",
address_line2="address 2", address_line2="address 2",
state_territory="NY", state_territory="NY" if not state_territory else state_territory,
zipcode="10002", zipcode="10002",
senior_official=so, senior_official=so,
requested_domain=domain, requested_domain=domain,
@ -1090,6 +1092,10 @@ def completed_domain_request( # noqa
investigator=investigator, investigator=investigator,
federal_agency=federal_agency, federal_agency=federal_agency,
) )
if city:
domain_request_kwargs["city"] = city
if has_about_your_organization: if has_about_your_organization:
domain_request_kwargs["about_your_organization"] = "e-Government" domain_request_kwargs["about_your_organization"] = "e-Government"
if has_anything_else: if has_anything_else:

View file

@ -1516,6 +1516,91 @@ class TestCreateFederalPortfolio(TestCase):
): ):
call_command("create_federal_portfolio", **kwargs) call_command("create_federal_portfolio", **kwargs)
@less_console_noise_decorator
def test_post_process_started_domain_requests_existing_portfolio(self):
"""Ensures that federal agency is cleared when agency name matches portfolio name.
As the name implies, this implicitly tests the "post_process_started_domain_requests" function.
"""
federal_agency_2 = FederalAgency.objects.create(agency="Sugarcane", federal_type=BranchChoices.EXECUTIVE)
# Test records with portfolios and no org names
# Create a portfolio. This script skips over "started"
portfolio = Portfolio.objects.create(organization_name="Sugarcane", creator=self.user)
# Create a domain request with matching org name
matching_request = completed_domain_request(
name="matching.gov",
status=DomainRequest.DomainRequestStatus.STARTED,
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=federal_agency_2,
user=self.user,
portfolio=portfolio,
)
# Create a request not in started (no change should occur)
matching_request_in_wrong_status = completed_domain_request(
name="kinda-matching.gov",
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=self.federal_agency,
user=self.user,
)
self.run_create_federal_portfolio(agency_name="Sugarcane", parse_requests=True)
self.run_create_federal_portfolio(agency_name="Test Federal Agency", parse_requests=True)
# Refresh from db
matching_request.refresh_from_db()
matching_request_in_wrong_status.refresh_from_db()
# Request with matching name should have federal_agency cleared
self.assertIsNone(matching_request.federal_agency)
self.assertIsNotNone(matching_request.portfolio)
self.assertEqual(matching_request.portfolio.organization_name, "Sugarcane")
# Request with matching name but wrong state should keep its federal agency
self.assertEqual(matching_request_in_wrong_status.federal_agency, self.federal_agency)
self.assertIsNotNone(matching_request_in_wrong_status.portfolio)
self.assertEqual(matching_request_in_wrong_status.portfolio.organization_name, "Test Federal Agency")
@less_console_noise_decorator
def test_post_process_started_domain_requests(self):
"""Tests that federal agency is cleared when agency name
matches an existing portfolio's name, even if the domain request isn't
directly on that portfolio."""
federal_agency_2 = FederalAgency.objects.create(agency="Sugarcane", federal_type=BranchChoices.EXECUTIVE)
# Create a request with matching federal_agency name but no direct portfolio association
matching_agency_request = completed_domain_request(
name="agency-match.gov",
status=DomainRequest.DomainRequestStatus.STARTED,
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=federal_agency_2,
user=self.user,
)
# Create a control request that shouldn't match
non_matching_request = completed_domain_request(
name="no-match.gov",
status=DomainRequest.DomainRequestStatus.STARTED,
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=self.federal_agency,
user=self.user,
)
# We expect the matching agency to have its fed agency cleared.
self.run_create_federal_portfolio(agency_name="Sugarcane", parse_requests=True)
matching_agency_request.refresh_from_db()
non_matching_request.refresh_from_db()
# Request with matching agency name should have federal_agency cleared
self.assertIsNone(matching_agency_request.federal_agency)
# Non-matching request should keep its federal_agency
self.assertIsNotNone(non_matching_request.federal_agency)
self.assertEqual(non_matching_request.federal_agency, self.federal_agency)
@less_console_noise_decorator
def test_create_single_portfolio(self): def test_create_single_portfolio(self):
"""Test portfolio creation with suborg and senior official.""" """Test portfolio creation with suborg and senior official."""
self.run_create_federal_portfolio(agency_name="Test Federal Agency", parse_requests=True) self.run_create_federal_portfolio(agency_name="Test Federal Agency", parse_requests=True)
@ -1588,6 +1673,34 @@ class TestCreateFederalPortfolio(TestCase):
self.assertTrue(all([creator == User.get_default_user() for creator in creators])) self.assertTrue(all([creator == User.get_default_user() for creator in creators]))
self.assertTrue(all([note == "Auto-generated record" for note in notes])) self.assertTrue(all([note == "Auto-generated record" for note in notes]))
def test_script_adds_requested_suborganization_information(self):
"""Tests that the script adds the requested suborg fields for domain requests"""
# Create a new domain request with some errant spacing
custom_suborg_request = completed_domain_request(
name="custom_org.gov",
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=self.executive_agency_2,
user=self.user,
organization_name=" requested org name ",
city="Austin ",
state_territory=DomainRequest.StateTerritoryChoices.TEXAS,
)
self.assertIsNone(custom_suborg_request.requested_suborganization)
self.assertIsNone(custom_suborg_request.suborganization_city)
self.assertIsNone(custom_suborg_request.suborganization_state_territory)
# Run the script and test it
self.run_create_federal_portfolio(branch="executive", parse_requests=True)
custom_suborg_request.refresh_from_db()
self.assertEqual(custom_suborg_request.requested_suborganization, "requested org name")
self.assertEqual(custom_suborg_request.suborganization_city, "Austin")
self.assertEqual(
custom_suborg_request.suborganization_state_territory, DomainRequest.StateTerritoryChoices.TEXAS
)
def test_create_multiple_portfolios_for_branch_executive(self): def test_create_multiple_portfolios_for_branch_executive(self):
"""Tests creating all portfolios under a given branch""" """Tests creating all portfolios under a given branch"""
federal_choice = DomainRequest.OrganizationChoices.FEDERAL federal_choice = DomainRequest.OrganizationChoices.FEDERAL

View file

@ -439,35 +439,47 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
username="usertest", username="usertest",
) )
self.expiringdomain, _ = Domain.objects.get_or_create( self.domaintorenew, _ = Domain.objects.get_or_create(
name="expiringdomain.gov", name="domainrenewal.gov",
) )
UserDomainRole.objects.get_or_create( UserDomainRole.objects.get_or_create(
user=self.user, domain=self.expiringdomain, role=UserDomainRole.Roles.MANAGER user=self.user, domain=self.domaintorenew, role=UserDomainRole.Roles.MANAGER
) )
DomainInformation.objects.get_or_create(creator=self.user, domain=self.expiringdomain) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domaintorenew)
self.portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user) self.portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
self.user.save() self.user.save()
def custom_is_expired(self): def expiration_date_one_year_out(self):
todays_date = datetime.today()
new_expiration_date = todays_date.replace(year=todays_date.year + 1)
return new_expiration_date
def custom_is_expired_false(self):
return False return False
def custom_is_expired_true(self):
return True
def custom_is_expiring(self): def custom_is_expiring(self):
return True return True
def custom_renew_domain(self):
self.domain_with_ip.expiration_date = self.expiration_date_one_year_out()
self.domain_with_ip.save()
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
def test_expiring_domain_on_detail_page_as_domain_manager(self): def test_expiring_domain_on_detail_page_as_domain_manager(self):
self.client.force_login(self.user) self.client.force_login(self.user)
with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object( with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object(
Domain, "is_expired", self.custom_is_expired Domain, "is_expired", self.custom_is_expired_false
): ):
self.assertEquals(self.expiringdomain.state, Domain.State.UNKNOWN) self.assertEquals(self.domaintorenew.state, Domain.State.UNKNOWN)
detail_page = self.client.get( detail_page = self.client.get(
reverse("domain", kwargs={"pk": self.expiringdomain.id}), reverse("domain", kwargs={"pk": self.domaintorenew.id}),
) )
self.assertContains(detail_page, "Expiring soon") self.assertContains(detail_page, "Expiring soon")
@ -498,17 +510,17 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
], ],
) )
expiringdomain2, _ = Domain.objects.get_or_create(name="bogusdomain2.gov") domaintorenew2, _ = Domain.objects.get_or_create(name="bogusdomain2.gov")
DomainInformation.objects.get_or_create( DomainInformation.objects.get_or_create(
creator=non_dom_manage_user, domain=expiringdomain2, portfolio=self.portfolio creator=non_dom_manage_user, domain=domaintorenew2, portfolio=self.portfolio
) )
non_dom_manage_user.refresh_from_db() non_dom_manage_user.refresh_from_db()
self.client.force_login(non_dom_manage_user) self.client.force_login(non_dom_manage_user)
with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object( with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object(
Domain, "is_expired", self.custom_is_expired Domain, "is_expired", self.custom_is_expired_false
): ):
detail_page = self.client.get( detail_page = self.client.get(
reverse("domain", kwargs={"pk": expiringdomain2.id}), reverse("domain", kwargs={"pk": domaintorenew2.id}),
) )
self.assertContains(detail_page, "Contact one of the listed domain managers to renew the domain.") self.assertContains(detail_page, "Contact one of the listed domain managers to renew the domain.")
@ -517,20 +529,164 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
def test_expiring_domain_on_detail_page_in_org_model_as_a_domain_manager(self): def test_expiring_domain_on_detail_page_in_org_model_as_a_domain_manager(self):
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org2", creator=self.user) portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org2", creator=self.user)
expiringdomain3, _ = Domain.objects.get_or_create(name="bogusdomain3.gov") domaintorenew3, _ = Domain.objects.get_or_create(name="bogusdomain3.gov")
UserDomainRole.objects.get_or_create(user=self.user, domain=expiringdomain3, role=UserDomainRole.Roles.MANAGER) UserDomainRole.objects.get_or_create(user=self.user, domain=domaintorenew3, role=UserDomainRole.Roles.MANAGER)
DomainInformation.objects.get_or_create(creator=self.user, domain=expiringdomain3, portfolio=portfolio) DomainInformation.objects.get_or_create(creator=self.user, domain=domaintorenew3, portfolio=portfolio)
self.user.refresh_from_db() self.user.refresh_from_db()
self.client.force_login(self.user) self.client.force_login(self.user)
with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object( with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object(
Domain, "is_expired", self.custom_is_expired Domain, "is_expired", self.custom_is_expired_false
): ):
detail_page = self.client.get( detail_page = self.client.get(
reverse("domain", kwargs={"pk": expiringdomain3.id}), reverse("domain", kwargs={"pk": domaintorenew3.id}),
) )
self.assertContains(detail_page, "Renew to maintain access") self.assertContains(detail_page, "Renew to maintain access")
@override_flag("domain_renewal", active=True)
def test_domain_renewal_form_and_sidebar_expiring(self):
self.client.force_login(self.user)
with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object(
Domain, "is_expiring", self.custom_is_expiring
):
# Grab the detail page
detail_page = self.client.get(
reverse("domain", kwargs={"pk": self.domaintorenew.id}),
)
# Make sure we see the link as a domain manager
self.assertContains(detail_page, "Renew to maintain access")
# Make sure we can see Renewal form on the sidebar since it's expiring
self.assertContains(detail_page, "Renewal form")
# Grab link to the renewal page
renewal_form_url = reverse("domain-renewal", kwargs={"pk": self.domaintorenew.id})
self.assertContains(detail_page, f'href="{renewal_form_url}"')
# Simulate clicking the link
response = self.client.get(renewal_form_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"Renew {self.domaintorenew.name}")
@override_flag("domain_renewal", active=True)
def test_domain_renewal_form_and_sidebar_expired(self):
self.client.force_login(self.user)
with patch.object(Domain, "is_expired", self.custom_is_expired_true), patch.object(
Domain, "is_expired", self.custom_is_expired_true
):
# Grab the detail page
detail_page = self.client.get(
reverse("domain", kwargs={"pk": self.domaintorenew.id}),
)
print("puglesss", self.domaintorenew.is_expired)
# Make sure we see the link as a domain manager
self.assertContains(detail_page, "Renew to maintain access")
# Make sure we can see Renewal form on the sidebar since it's expired
self.assertContains(detail_page, "Renewal form")
# Grab link to the renewal page
renewal_form_url = reverse("domain-renewal", kwargs={"pk": self.domaintorenew.id})
self.assertContains(detail_page, f'href="{renewal_form_url}"')
# Simulate clicking the link
response = self.client.get(renewal_form_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"Renew {self.domaintorenew.name}")
@override_flag("domain_renewal", active=True)
def test_domain_renewal_form_your_contact_info_edit(self):
with less_console_noise():
# Start on the Renewal page for the domain
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}))
# Verify we see "Your contact information" on the renewal form
self.assertContains(renewal_page, "Your contact information")
# Verify that the "Edit" button for Your contact is there and links to correct URL
edit_button_url = reverse("user-profile")
self.assertContains(renewal_page, f'href="{edit_button_url}"')
# Simulate clicking on edit button
edit_page = renewal_page.click(href=edit_button_url, index=1)
self.assertEqual(edit_page.status_code, 200)
self.assertContains(edit_page, "Review the details below and update any required information")
@override_flag("domain_renewal", active=True)
def test_domain_renewal_form_security_email_edit(self):
with less_console_noise():
# Start on the Renewal page for the domain
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}))
# Verify we see "Security email" on the renewal form
self.assertContains(renewal_page, "Security email")
# Verify we see "strong recommend" blurb
self.assertContains(renewal_page, "We strongly recommend that you provide a security email.")
# Verify that the "Edit" button for Security email is there and links to correct URL
edit_button_url = reverse("domain-security-email", kwargs={"pk": self.domain_with_ip.id})
self.assertContains(renewal_page, f'href="{edit_button_url}"')
# Simulate clicking on edit button
edit_page = renewal_page.click(href=edit_button_url, index=1)
self.assertEqual(edit_page.status_code, 200)
self.assertContains(edit_page, "A security contact should be capable of evaluating")
@override_flag("domain_renewal", active=True)
def test_domain_renewal_form_domain_manager_edit(self):
with less_console_noise():
# Start on the Renewal page for the domain
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}))
# Verify we see "Domain managers" on the renewal form
self.assertContains(renewal_page, "Domain managers")
# Verify that the "Edit" button for Domain managers is there and links to correct URL
edit_button_url = reverse("domain-users", kwargs={"pk": self.domain_with_ip.id})
self.assertContains(renewal_page, f'href="{edit_button_url}"')
# Simulate clicking on edit button
edit_page = renewal_page.click(href=edit_button_url, index=1)
self.assertEqual(edit_page.status_code, 200)
self.assertContains(edit_page, "Domain managers can update all information related to a domain")
@override_flag("domain_renewal", active=True)
def test_ack_checkbox_not_checked(self):
# Grab the renewal URL
renewal_url = reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})
# Test that the checkbox is not checked
response = self.client.post(renewal_url, data={"submit_button": "next"})
error_message = "Check the box if you read and agree to the requirements for operating a .gov domain."
self.assertContains(response, error_message)
@override_flag("domain_renewal", active=True)
def test_ack_checkbox_checked(self):
# Grab the renewal URL
with patch.object(Domain, "renew_domain", self.custom_renew_domain):
renewal_url = reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})
# Click the check, and submit
response = self.client.post(renewal_url, data={"is_policy_acknowledged": "on", "submit_button": "next"})
# Check that it redirects after a successfully submits
self.assertRedirects(response, reverse("domain", kwargs={"pk": self.domain_with_ip.id}))
# Check for the updated expiration
formatted_new_expiration_date = self.expiration_date_one_year_out().strftime("%b. %-d, %Y")
redirect_response = self.client.get(reverse("domain", kwargs={"pk": self.domain_with_ip.id}), follow=True)
self.assertContains(redirect_response, formatted_new_expiration_date)
class TestDomainManagers(TestDomainOverview): class TestDomainManagers(TestDomainOverview):
@classmethod @classmethod
@ -2660,7 +2816,6 @@ class TestDomainRenewal(TestWithUser):
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete() UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete()
self.client.force_login(self.user) self.client.force_login(self.user)
domains_page = self.client.get("/") domains_page = self.client.get("/")
self.assertNotContains(domains_page, "Expiring soon")
self.assertNotContains(domains_page, "will expire soon") self.assertNotContains(domains_page, "will expire soon")
@less_console_noise_decorator @less_console_noise_decorator
@ -2698,5 +2853,4 @@ class TestDomainRenewal(TestWithUser):
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete() UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete()
self.client.force_login(self.user) self.client.force_login(self.user)
domains_page = self.client.get("/") domains_page = self.client.get("/")
self.assertNotContains(domains_page, "Expiring soon")
self.assertNotContains(domains_page, "will expire soon") self.assertNotContains(domains_page, "will expire soon")

View file

@ -14,6 +14,7 @@ from .domain import (
DomainInvitationCancelView, DomainInvitationCancelView,
DomainDeleteUserView, DomainDeleteUserView,
PrototypeDomainDNSRecordView, PrototypeDomainDNSRecordView,
DomainRenewalView,
) )
from .user_profile import UserProfileView, FinishProfileSetupView from .user_profile import UserProfileView, FinishProfileSetupView
from .health import * from .health import *

View file

@ -12,11 +12,11 @@ from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.db import IntegrityError from django.db import IntegrityError
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect, render, get_object_or_404
from django.urls import reverse from django.urls import reverse
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django.conf import settings from django.conf import settings
from registrar.forms.domain import DomainSuborganizationForm from registrar.forms.domain import DomainSuborganizationForm, DomainRenewalForm
from registrar.models import ( from registrar.models import (
Domain, Domain,
DomainRequest, DomainRequest,
@ -311,6 +311,47 @@ class DomainView(DomainBaseView):
self._update_session_with_domain() self._update_session_with_domain()
class DomainRenewalView(DomainView):
"""Domain detail overview page."""
template_name = "domain_renewal.html"
def post(self, request, pk):
domain = get_object_or_404(Domain, id=pk)
form = DomainRenewalForm(request.POST)
if form.is_valid():
# check for key in the post request data
if "submit_button" in request.POST:
try:
domain.renew_domain()
messages.success(request, "This domain has been renewed for one year.")
except Exception:
messages.error(
request,
"This domain has not been renewed for one year, "
"please email help@get.gov if this problem persists.",
)
return HttpResponseRedirect(reverse("domain", kwargs={"pk": pk}))
# if not valid, render the template with error messages
# passing editable, has_domain_renewal_flag, and is_editable for re-render
return render(
request,
"domain_renewal.html",
{
"domain": domain,
"form": form,
"is_editable": True,
"has_domain_renewal_flag": True,
"is_domain_manager": True,
},
)
class DomainOrgNameAddressView(DomainFormBaseView): class DomainOrgNameAddressView(DomainFormBaseView):
"""Organization view""" """Organization view"""