Merge remote-tracking branch 'origin/main' into rjm/1027-groups-permissions-logging

This commit is contained in:
Rachid Mrad 2023-09-27 13:55:40 -04:00
commit fd860998fb
No known key found for this signature in database
GPG key ID: EF38E4CEC4A8F3CF
35 changed files with 591 additions and 38 deletions

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -11,6 +11,7 @@ applications:
command: ./run.sh command: ./run.sh
health-check-type: http health-check-type: http
health-check-http-endpoint: /health health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env: env:
# Send stdout and stderr straight to the terminal without buffering # Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup PYTHONUNBUFFERED: yup

View file

@ -67,6 +67,13 @@ class RegistryError(Exception):
def should_retry(self): def should_retry(self):
return self.code == ErrorCode.COMMAND_FAILED return self.code == ErrorCode.COMMAND_FAILED
# connection errors have error code of None and [Errno 99] in the err message
def is_connection_error(self):
return self.code is None
def is_session_error(self):
return self.code is not None and (self.code >= 2501 and self.code <= 2502)
def is_server_error(self): def is_server_error(self):
return self.code is not None and (self.code >= 2400 and self.code <= 2500) return self.code is not None and (self.code >= 2400 and self.code <= 2500)

View file

@ -638,6 +638,21 @@ class DomainApplicationAdmin(ListHeaderAdmin):
return super().change_view(request, object_id, form_url, extra_context) return super().change_view(request, object_id, form_url, extra_context)
class TransitionDomainAdmin(ListHeaderAdmin):
"""Custom transition domain admin class."""
# Columns
list_display = [
"username",
"domain_name",
"status",
"email_sent",
]
search_fields = ["username", "domain_name"]
search_help_text = "Search by user or domain name."
class DomainInformationInline(admin.StackedInline): class DomainInformationInline(admin.StackedInline):
"""Edit a domain information on the domain page. """Edit a domain information on the domain page.
We had issues inheriting from both StackedInline We had issues inheriting from both StackedInline
@ -729,7 +744,23 @@ class DomainAdmin(ListHeaderAdmin):
obj.place_client_hold() obj.place_client_hold()
obj.save() obj.save()
except Exception as err: except Exception as err:
self.message_user(request, err, messages.ERROR) # if error is an error from the registry, display useful
# and readable error
if err.code:
self.message_user(
request,
f"Error placing the hold with the registry: {err}",
messages.ERROR,
)
elif err.is_connection_error():
self.message_user(
request,
"Error connecting to the registry",
messages.ERROR,
)
else:
# all other type error messages, display the error
self.message_user(request, err, messages.ERROR)
else: else:
self.message_user( self.message_user(
request, request,
@ -746,7 +777,23 @@ class DomainAdmin(ListHeaderAdmin):
obj.revert_client_hold() obj.revert_client_hold()
obj.save() obj.save()
except Exception as err: except Exception as err:
self.message_user(request, err, messages.ERROR) # if error is an error from the registry, display useful
# and readable error
if err.code:
self.message_user(
request,
f"Error removing the hold in the registry: {err}",
messages.ERROR,
)
elif err.is_connection_error():
self.message_user(
request,
"Error connecting to the registry",
messages.ERROR,
)
else:
# all other type error messages, display the error
self.message_user(request, err, messages.ERROR)
else: else:
self.message_user( self.message_user(
request, request,
@ -804,4 +851,4 @@ admin.site.register(models.Nameserver, MyHostAdmin)
admin.site.register(models.Website, WebsiteAdmin) admin.site.register(models.Website, WebsiteAdmin)
admin.site.register(models.PublicContact, AuditedAdmin) admin.site.register(models.PublicContact, AuditedAdmin)
admin.site.register(models.DomainApplication, DomainApplicationAdmin) admin.site.register(models.DomainApplication, DomainApplicationAdmin)
admin.site.register(models.TransitionDomain, AuditedAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin)

View file

@ -399,6 +399,11 @@ a.usa-button--unstyled:visited {
border-color: color('accent-cool-lighter'); border-color: color('accent-cool-lighter');
} }
.dotgov-status-box--action-need {
background-color: color('warning-lighter');
border-color: color('warning');
}
#wrapper { #wrapper {
padding-top: units(3); padding-top: units(3);
padding-bottom: units(6) * 2 ; //Workaround because USWDS units jump from 10 to 15 padding-bottom: units(6) * 2 ; //Workaround because USWDS units jump from 10 to 15

View file

@ -0,0 +1,65 @@
"""Data migration: Generate fake transition domains, replacing existing ones."""
import logging
from django.core.management import BaseCommand
from registrar.models import TransitionDomain, Domain
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Generate test transition domains from existing domains"
# Generates test transition domains for testing send_domain_invitations script.
# Running this script removes all existing transition domains, so use with caution.
# Transition domains are created with email addresses provided as command line
# argument. Email addresses for testing are passed as comma delimited list of
# email addresses, and are required to be provided. Email addresses from the list
# are assigned to transition domains at time of creation.
def add_arguments(self, parser):
"""Add command line arguments."""
parser.add_argument(
"-e",
"--emails",
required=True,
dest="emails",
help="Comma-delimited list of email addresses to be used for testing",
)
def handle(self, **options):
"""Delete existing TransitionDomains. Generate test ones.
expects options[emails]; emails will be assigned to transition
domains at the time of creation"""
# split options[emails] into an array of test emails
test_emails = options["emails"].split(",")
if len(test_emails) > 0:
# set up test data
self.delete_test_transition_domains()
self.load_test_transition_domains(test_emails)
else:
logger.error("list of emails for testing is required")
def load_test_transition_domains(self, test_emails: list):
"""Load test transition domains"""
# counter for test_emails index
test_emails_counter = 0
# Need to get actual domain names from the database for this test
real_domains = Domain.objects.all()
for real_domain in real_domains:
TransitionDomain.objects.create(
username=test_emails[test_emails_counter % len(test_emails)],
domain_name=real_domain.name,
status="created",
email_sent=False,
)
test_emails_counter += 1
def delete_test_transition_domains(self):
self.transition_domains = TransitionDomain.objects.all()
for transition_domain in self.transition_domains:
transition_domain.delete()

View file

@ -0,0 +1,142 @@
"""Data migration: Send domain invitations once to existing customers."""
import logging
import copy
from django.core.management import BaseCommand
from registrar.models import TransitionDomain
from ...utility.email import send_templated_email, EmailSendingError
from typing import List
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Send domain invitations once to existing customers."
# this array is used to store and process the transition_domains
transition_domains: List[str] = []
# this array is used to store domains with errors, which are not
# sent emails; this array is used to update the succesful
# transition_domains to email_sent=True, and also to report
# out errors
domains_with_errors: List[str] = []
# this array is used to store email_context; each item in the array
# contains the context for a single email; single emails may be 1
# or more transition_domains, as they are grouped by username
emails_to_send: List[str] = []
def add_arguments(self, parser):
"""Add command line arguments."""
parser.add_argument(
"-s",
"--send_emails",
action="store_true",
default=False,
dest="send_emails",
help="Send emails ",
)
def handle(self, **options):
"""Process the objects in TransitionDomain."""
logger.info("checking domains and preparing emails")
# Get all TransitionDomain objects
self.transition_domains = TransitionDomain.objects.filter(
email_sent=False,
).order_by("username")
self.build_emails_to_send_array()
if options["send_emails"]:
logger.info("about to send emails")
self.send_emails()
logger.info("done sending emails")
self.update_domains_as_sent()
logger.info("done sending emails and updating transition_domains")
else:
logger.info("not sending emails")
def build_emails_to_send_array(self):
"""this method sends emails to distinct usernames"""
# data structure to hold email context for a single email;
# transition_domains ordered by username, a single email_context
# may include information from more than one transition_domain
email_context = {"email": ""}
# loop through all transition_domains; group them by username
# into emails_to_send_array
for transition_domain in self.transition_domains:
# attempt to get the domain from domain objects; if there is
# an error getting the domain, skip this domain and add it to
# domains_with_errors
try:
# if prior username does not match current username
if (
not email_context["email"]
or email_context["email"] != transition_domain.username
):
# if not first in list of transition_domains
if email_context["email"]:
# append the email context to the emails_to_send array
self.emails_to_send.append(copy.deepcopy(email_context))
email_context["domains"] = []
email_context["email"] = transition_domain.username
email_context["domains"].append(transition_domain.domain_name)
except Exception as err:
# error condition if domain not in database
self.domains_with_errors.append(
copy.deepcopy(transition_domain.domain_name)
)
logger.error(
f"error retrieving domain {transition_domain.domain_name}: {err}"
)
# if there are at least one more transition domains than errors,
# then append one more item
if len(self.transition_domains) > len(self.domains_with_errors):
self.emails_to_send.append(email_context)
def send_emails(self):
if len(self.emails_to_send) > 0:
for email_data in self.emails_to_send:
self.send_email(email_data)
else:
logger.info("no emails to send")
def send_email(self, email_data):
try:
send_templated_email(
"emails/transition_domain_invitation.txt",
"emails/transition_domain_invitation_subject.txt",
to_address=email_data["email"],
context={
"domains": email_data["domains"],
},
)
# success message is logged
logger.info(
f"email sent successfully to {email_data['email']} for "
f"{[domain for domain in email_data['domains']]}"
)
except EmailSendingError as err:
logger.error(
f"email did not send successfully to {email_data['email']} "
f"for {[domain for domain in email_data['domains']]}"
f": {err}"
)
# if email failed to send, set error in domains_with_errors for each
# domain in the email so that transition domain email_sent is not set
# to True
for domain in email_data["domains"]:
self.domains_with_errors.append(domain)
def update_domains_as_sent(self):
"""set email_sent to True in all transition_domains which have
been processed successfully"""
for transition_domain in self.transition_domains:
if transition_domain.domain_name not in self.domains_with_errors:
transition_domain.email_sent = True
transition_domain.save()

View file

@ -0,0 +1,24 @@
# Generated by Django 4.2.1 on 2023-09-27 00:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0031_transitiondomain_and_more"),
]
operations = [
migrations.AlterField(
model_name="transitiondomain",
name="status",
field=models.CharField(
blank=True,
choices=[("ready", "Ready"), ("hold", "Hold")],
default="ready",
help_text="domain status during the transfer",
max_length=255,
verbose_name="Status",
),
),
]

View file

@ -634,13 +634,25 @@ class Domain(TimeStampedModel, DomainHelper):
"""This domain should not be active. """This domain should not be active.
may raises RegistryError, should be caught or handled correctly by caller""" may raises RegistryError, should be caught or handled correctly by caller"""
request = commands.UpdateDomain(name=self.name, add=[self.clientHoldStatus()]) request = commands.UpdateDomain(name=self.name, add=[self.clientHoldStatus()])
registry.send(request, cleaned=True) try:
registry.send(request, cleaned=True)
self._invalidate_cache()
except RegistryError as err:
# if registry error occurs, log the error, and raise it as well
logger.error(f"registry error placing client hold: {err}")
raise (err)
def _remove_client_hold(self): def _remove_client_hold(self):
"""This domain is okay to be active. """This domain is okay to be active.
may raises RegistryError, should be caught or handled correctly by caller""" may raises RegistryError, should be caught or handled correctly by caller"""
request = commands.UpdateDomain(name=self.name, rem=[self.clientHoldStatus()]) request = commands.UpdateDomain(name=self.name, rem=[self.clientHoldStatus()])
registry.send(request, cleaned=True) try:
registry.send(request, cleaned=True)
self._invalidate_cache()
except RegistryError as err:
# if registry error occurs, log the error, and raise it as well
logger.error(f"registry error removing client hold: {err}")
raise (err)
def _delete_domain(self): def _delete_domain(self):
"""This domain should be deleted from the registry """This domain should be deleted from the registry
@ -773,7 +785,9 @@ class Domain(TimeStampedModel, DomainHelper):
administrative_contact.domain = self administrative_contact.domain = self
administrative_contact.save() administrative_contact.save()
@transition(field="state", source=State.READY, target=State.ON_HOLD) @transition(
field="state", source=[State.READY, State.ON_HOLD], target=State.ON_HOLD
)
def place_client_hold(self): def place_client_hold(self):
"""place a clienthold on a domain (no longer should resolve)""" """place a clienthold on a domain (no longer should resolve)"""
# TODO - ensure all requirements for client hold are made here # TODO - ensure all requirements for client hold are made here
@ -782,7 +796,7 @@ class Domain(TimeStampedModel, DomainHelper):
self._place_client_hold() self._place_client_hold()
# TODO -on the client hold ticket any additional error handling here # TODO -on the client hold ticket any additional error handling here
@transition(field="state", source=State.ON_HOLD, target=State.READY) @transition(field="state", source=[State.READY, State.ON_HOLD], target=State.READY)
def revert_client_hold(self): def revert_client_hold(self):
"""undo a clienthold placed on a domain""" """undo a clienthold placed on a domain"""

View file

@ -3,15 +3,16 @@ from django.db import models
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
class StatusChoices(models.TextChoices):
READY = "ready", "Ready"
HOLD = "hold", "Hold"
class TransitionDomain(TimeStampedModel): class TransitionDomain(TimeStampedModel):
"""Transition Domain model stores information about the """Transition Domain model stores information about the
state of a domain upon transition between registry state of a domain upon transition between registry
providers""" providers"""
class StatusChoices(models.TextChoices):
CREATED = "created", "Created"
HOLD = "hold", "Hold"
username = models.TextField( username = models.TextField(
null=False, null=False,
blank=False, blank=False,
@ -27,6 +28,7 @@ class TransitionDomain(TimeStampedModel):
max_length=255, max_length=255,
null=False, null=False,
blank=True, blank=True,
default=StatusChoices.READY,
choices=StatusChoices.choices, choices=StatusChoices.choices,
verbose_name="Status", verbose_name="Status",
help_text="domain status during the transfer", help_text="domain status during the transfer",
@ -39,4 +41,9 @@ class TransitionDomain(TimeStampedModel):
) )
def __str__(self): def __str__(self):
return self.username return (
f"username: {self.username} "
f"domainName: {self.domain_name} "
f"status: {self.status} "
f"email sent: {self.email_sent} "
)

View file

@ -26,7 +26,7 @@
<li>Domain meets our naming requirements</li> <li>Domain meets our naming requirements</li>
</ul> </ul>
<p> You can <a href="{% url 'todo' %}"><del>check the status</del></a> <p> You can <a href="{% url 'home' %}">check the status</a>
of your request at any time. We'll email you with any questions or when we of your request at any time. We'll email you with any questions or when we
complete our review.</p> complete our review.</p>

View file

@ -5,6 +5,28 @@
{{ block.super }} {{ block.super }}
<div class="margin-top-4 tablet:grid-col-10"> <div class="margin-top-4 tablet:grid-col-10">
<div
class="usa-summary-box dotgov-status-box margin-top-3 padding-left-2{% if domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED%} dotgov-status-box--action-need{% endif %}"
role="region"
aria-labelledby="summary-box-key-information"
>
<div class="usa-summary-box__body">
<p class="usa-summary-box__heading font-sans-md margin-bottom-0"
id="summary-box-key-information"
>
<span class="text-bold text-primary-darker">
Status:
</span>
{% if domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED%}
DNS Needed
{% else %}
{{ domain.state|title }}
{% endif %}
</p>
</div>
</div>
<br>
{% url 'domain-nameservers' pk=domain.id as url %} {% url 'domain-nameservers' pk=domain.id as url %}
{% if domain.nameservers|length > 0 %} {% if domain.nameservers|length > 0 %}
{% include "includes/summary_item.html" with title='DNS name servers' value=domain.nameservers list='true' edit_link=url %} {% include "includes/summary_item.html" with title='DNS name servers' value=domain.nameservers list='true' edit_link=url %}

View file

@ -14,8 +14,6 @@
<p>Before your domain can be used we'll need information about your domain <p>Before your domain can be used we'll need information about your domain
name servers.</p> name servers.</p>
<p><a class="usa-link" href="{% url "todo" %}">Get help with domain servers.</a></p>
{% include "includes/required_fields.html" %} {% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container"> <form class="usa-form usa-form--large" method="post" novalidate id="form-container">

View file

@ -1,6 +1,32 @@
You have been invited to manage the domain {{ domain.name }} on get.gov, {% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
the registrar for .gov domain names. Hi.
To accept your invitation, go to <{{ domain_url }}>. {{ full_name }} has added you as a manager on {{ domain.name }}.
You will need to log in with a Login.gov account using this email address. YOU NEED A LOGIN.GOV ACCOUNT
Youll need a Login.gov account to manage your .gov domain. Login.gov provides
a simple and secure process for signing into many government services with one
account. If you dont already have one, follow these steps to create your
Login.gov account <https://login.gov/help/get-started/create-your-account/>.
DOMAIN MANAGEMENT
As a .gov domain manager you can add or update information about your domain.
Youll also serve as a contact for your .gov domain. Please keep your contact
information updated. Learn more about domain management <https://get.gov/help/>.
SOMETHING WRONG?
If youre not affiliated with {{ domain.name }} or think you received this
message in error, contact the .gov team <https://get.gov/help/#contact-us>.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for
using a .gov domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Visit <https://get.gov>
{% endautoescape %}

View file

@ -1 +1 @@
You are invited to manage {{ domain.name }} on get.gov Youve been added to a .gov domain

View file

@ -0,0 +1,29 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi.
You have been added as a manager on {% if domains|length > 1 %}multiple domains (listed below){% else %}{{ domains.0 }}{% endif %}.
YOU NEED A LOGIN.GOV ACCOUNT
Youll need a Login.gov account to manage your .gov domain{% if domains|length > 1 %}s{% endif %}. Login.gov provides a simple and secure process for signing into many government services with one account. If you dont already have one, follow these steps to create your Login.gov account <https://login.gov/help/get-started/create-your-account/>.
DOMAIN MANAGEMENT
As a .gov domain manager you can add or update information about your domain{% if domains|length > 1 %}s{% endif %}. Youll also serve as a contact for your .gov domain{% if domains|length > 1 %}s{% endif %}. Please keep your contact information updated. Learn more about domain management <https://get.gov/help/>.
{% if domains|length > 1 %}
DOMAINS
{% for domain in domains %} {{ domain }}
{% endfor %}{% else %}
{% endif %}
SOMETHING WRONG?
If youre not affiliated with {{ domain }} or think you received this message in error, contact the .gov team <https://get.gov/help/#contact-us>.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Visit <https://get.gov>
{% endautoescape %}

View file

@ -0,0 +1 @@
You've been added to a .gov domain

View file

@ -33,14 +33,18 @@
</thead> </thead>
<tbody> <tbody>
{% for domain in domains %} {% for domain in domains %}
{% comment %} ticket 796
{% if domain.application_status == "approved" or (domain.application does not exist) %} {% endcomment %}
<tr> <tr>
<th th scope="row" role="rowheader" data-label="Domain name"> <th th scope="row" role="rowheader" data-label="Domain name">
{{ domain.name }} {{ domain.name }}
</th> </th>
<td data-sort-value="{{ domain.created_time|date:"U" }}" data-label="Date created">{{ domain.created_time|date }}</td> <td data-sort-value="{{ domain.created_time|date:"U" }}" data-label="Date created">{{ domain.created_time|date }}</td>
<td data-label="Status">{{ domain.application_status|title }}</td> <td data-label="Status">
{% if domain.state == "unknown" or domain.state == "dns needed"%}
DNS Needed
{% else %}
{{ domain.state|title }}
{% endif %}
</td>
<td> <td>
<a href="{% url "domain" pk=domain.pk %}"> <a href="{% url "domain" pk=domain.pk %}">
<svg <svg
@ -50,9 +54,15 @@
role="img" role="img"
width="24" width="24"
> >
{% if domain.state == "deleted" or domain.state == "on hold" %}
<use xlink:href="{%static 'img/sprite.svg'%}#visibility"></use>
</svg>
View <span class="usa-sr-only">{{ domain.name }}</span>
{% else %}
<use xlink:href="{%static 'img/sprite.svg'%}#settings"></use> <use xlink:href="{%static 'img/sprite.svg'%}#settings"></use>
</svg> </svg>
Manage <span class="usa-sr-only">{{ domain.name }}</span> Manage <span class="usa-sr-only">{{ domain.name }}</span>
{% endif %}
</a> </a>
</td> </td>
</tr> </tr>

View file

@ -472,6 +472,7 @@ def completed_application(
has_anything_else=True, has_anything_else=True,
status=DomainApplication.STARTED, status=DomainApplication.STARTED,
user=False, user=False,
name="city.gov",
): ):
"""A completed domain application.""" """A completed domain application."""
if not user: if not user:
@ -483,7 +484,7 @@ def completed_application(
email="testy@town.com", email="testy@town.com",
phone="(555) 555 5555", phone="(555) 555 5555",
) )
domain, _ = DraftDomain.objects.get_or_create(name="city.gov") domain, _ = DraftDomain.objects.get_or_create(name=name)
alt, _ = Website.objects.get_or_create(website="city1.gov") alt, _ = Website.objects.get_or_create(website="city1.gov")
current, _ = Website.objects.get_or_create(website="city.com") current, _ = Website.objects.get_or_create(website="city.com")
you, _ = Contact.objects.get_or_create( you, _ = Contact.objects.get_or_create(

View file

@ -20,6 +20,8 @@ from .common import MockEppLib
from epplibwrapper import ( from epplibwrapper import (
commands, commands,
common, common,
RegistryError,
ErrorCode,
) )
@ -702,7 +704,7 @@ class TestRegistrantDNSSEC(TestCase):
raise raise
class TestAnalystClientHold(TestCase): class TestAnalystClientHold(MockEppLib):
"""Rule: Analysts may suspend or restore a domain by using client hold""" """Rule: Analysts may suspend or restore a domain by using client hold"""
def setUp(self): def setUp(self):
@ -711,18 +713,50 @@ class TestAnalystClientHold(TestCase):
Given the analyst is logged in Given the analyst is logged in
And a domain exists in the registry And a domain exists in the registry
""" """
pass super().setUp()
# for the tests, need a domain in the ready state
self.domain, _ = Domain.objects.get_or_create(
name="fake.gov", state=Domain.State.READY
)
# for the tests, need a domain in the on_hold state
self.domain_on_hold, _ = Domain.objects.get_or_create(
name="fake-on-hold.gov", state=Domain.State.ON_HOLD
)
def tearDown(self):
Domain.objects.all().delete()
super().tearDown()
@skip("not implemented yet")
def test_analyst_places_client_hold(self): def test_analyst_places_client_hold(self):
""" """
Scenario: Analyst takes a domain off the internet Scenario: Analyst takes a domain off the internet
When `domain.place_client_hold()` is called When `domain.place_client_hold()` is called
Then `CLIENT_HOLD` is added to the domain's statuses Then `CLIENT_HOLD` is added to the domain's statuses
""" """
raise self.domain.place_client_hold()
self.mockedSendFunction.assert_has_calls(
[
call(
commands.UpdateDomain(
name="fake.gov",
add=[
common.Status(
state=Domain.Status.CLIENT_HOLD,
description="",
lang="en",
)
],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
)
]
)
self.assertEquals(self.domain.state, Domain.State.ON_HOLD)
@skip("not implemented yet")
def test_analyst_places_client_hold_idempotent(self): def test_analyst_places_client_hold_idempotent(self):
""" """
Scenario: Analyst tries to place client hold twice Scenario: Analyst tries to place client hold twice
@ -730,9 +764,30 @@ class TestAnalystClientHold(TestCase):
When `domain.place_client_hold()` is called When `domain.place_client_hold()` is called
Then Domain returns normally (without error) Then Domain returns normally (without error)
""" """
raise self.domain_on_hold.place_client_hold()
self.mockedSendFunction.assert_has_calls(
[
call(
commands.UpdateDomain(
name="fake-on-hold.gov",
add=[
common.Status(
state=Domain.Status.CLIENT_HOLD,
description="",
lang="en",
)
],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
)
]
)
self.assertEquals(self.domain_on_hold.state, Domain.State.ON_HOLD)
@skip("not implemented yet")
def test_analyst_removes_client_hold(self): def test_analyst_removes_client_hold(self):
""" """
Scenario: Analyst restores a suspended domain Scenario: Analyst restores a suspended domain
@ -740,9 +795,30 @@ class TestAnalystClientHold(TestCase):
When `domain.remove_client_hold()` is called When `domain.remove_client_hold()` is called
Then `CLIENT_HOLD` is no longer in the domain's statuses Then `CLIENT_HOLD` is no longer in the domain's statuses
""" """
raise self.domain_on_hold.revert_client_hold()
self.mockedSendFunction.assert_has_calls(
[
call(
commands.UpdateDomain(
name="fake-on-hold.gov",
rem=[
common.Status(
state=Domain.Status.CLIENT_HOLD,
description="",
lang="en",
)
],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
)
]
)
self.assertEquals(self.domain_on_hold.state, Domain.State.READY)
@skip("not implemented yet")
def test_analyst_removes_client_hold_idempotent(self): def test_analyst_removes_client_hold_idempotent(self):
""" """
Scenario: Analyst tries to remove client hold twice Scenario: Analyst tries to remove client hold twice
@ -750,16 +826,54 @@ class TestAnalystClientHold(TestCase):
When `domain.remove_client_hold()` is called When `domain.remove_client_hold()` is called
Then Domain returns normally (without error) Then Domain returns normally (without error)
""" """
raise self.domain.revert_client_hold()
self.mockedSendFunction.assert_has_calls(
[
call(
commands.UpdateDomain(
name="fake.gov",
rem=[
common.Status(
state=Domain.Status.CLIENT_HOLD,
description="",
lang="en",
)
],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
)
]
)
self.assertEquals(self.domain.state, Domain.State.READY)
@skip("not implemented yet")
def test_update_is_unsuccessful(self): def test_update_is_unsuccessful(self):
""" """
Scenario: An update to place or remove client hold is unsuccessful Scenario: An update to place or remove client hold is unsuccessful
When an error is returned from epplibwrapper When an error is returned from epplibwrapper
Then a user-friendly error message is returned for displaying on the web Then a user-friendly error message is returned for displaying on the web
""" """
raise
def side_effect(_request, cleaned):
raise RegistryError(code=ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION)
patcher = patch("registrar.models.domain.registry.send")
mocked_send = patcher.start()
mocked_send.side_effect = side_effect
# if RegistryError is raised, admin formats user-friendly
# error message if error is_client_error, is_session_error, or
# is_server_error; so test for those conditions
with self.assertRaises(RegistryError) as err:
self.domain.place_client_hold()
self.assertTrue(
err.is_client_error() or err.is_session_error() or err.is_server_error()
)
patcher.stop()
class TestAnalystLock(TestCase): class TestAnalystLock(TestCase):

View file

@ -25,6 +25,7 @@ from registrar.models import (
from registrar.views.application import ApplicationWizard, Step from registrar.views.application import ApplicationWizard, Step
from .common import less_console_noise from .common import less_console_noise
from .common import MockEppLib
class TestViews(TestCase): class TestViews(TestCase):
@ -47,8 +48,9 @@ class TestViews(TestCase):
self.assertIn("/login?next=/register/", response.headers["Location"]) self.assertIn("/login?next=/register/", response.headers["Location"])
class TestWithUser(TestCase): class TestWithUser(MockEppLib):
def setUp(self): def setUp(self):
super().setUp()
username = "test_user" username = "test_user"
first_name = "First" first_name = "First"
last_name = "Last" last_name = "Last"
@ -59,6 +61,7 @@ class TestWithUser(TestCase):
def tearDown(self): def tearDown(self):
# delete any applications too # delete any applications too
super().tearDown()
DomainApplication.objects.all().delete() DomainApplication.objects.all().delete()
self.user.delete() self.user.delete()
@ -91,6 +94,7 @@ class LoggedInTests(TestWithUser):
response = self.client.get("/") response = self.client.get("/")
# count = 2 because it is also in screenreader content # count = 2 because it is also in screenreader content
self.assertContains(response, "igorville.gov", count=2) self.assertContains(response, "igorville.gov", count=2)
self.assertContains(response, "DNS Needed")
# clean up # clean up
role.delete() role.delete()
@ -1079,6 +1083,7 @@ class TestWithDomainPermissions(TestWithUser):
self.domain_information.delete() self.domain_information.delete()
if hasattr(self.domain, "contacts"): if hasattr(self.domain, "contacts"):
self.domain.contacts.all().delete() self.domain.contacts.all().delete()
DomainApplication.objects.all().delete()
self.domain.delete() self.domain.delete()
self.role.delete() self.role.delete()
except ValueError: # pass if already deleted except ValueError: # pass if already deleted
@ -1140,6 +1145,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
# click the "Edit" link # click the "Edit" link
detail_page = home_page.click("Manage") detail_page = home_page.click("Manage")
self.assertContains(detail_page, "igorville.gov") self.assertContains(detail_page, "igorville.gov")
self.assertContains(detail_page, "Status")
def test_domain_user_management(self): def test_domain_user_management(self):
response = self.client.get( response = self.client.get(
@ -1197,6 +1203,10 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
EMAIL = "mayor@igorville.gov" EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete() User.objects.filter(email=EMAIL).delete()
self.domain_information, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain
)
add_page = self.app.get( add_page = self.app.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id}) reverse("domain-users-add", kwargs={"pk": self.domain.id})
) )
@ -1218,6 +1228,10 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
EMAIL = "mayor@igorville.gov" EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete() User.objects.filter(email=EMAIL).delete()
self.domain_information, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain
)
mock_client = MagicMock() mock_client = MagicMock()
mock_client_instance = mock_client.return_value mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client): with boto3_mocking.clients.handler_for("sesv2", mock_client):
@ -1270,6 +1284,11 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
add_page = self.app.get( add_page = self.app.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id}) reverse("domain-users-add", kwargs={"pk": self.domain.id})
) )
self.domain_information, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = EMAIL add_page.form["email"] = EMAIL
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1293,6 +1312,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
) )
self.assertContains(page, "Domain name servers") self.assertContains(page, "Domain name servers")
@skip("Broken by adding registry connection fix in ticket 848")
def test_domain_nameservers_form(self): def test_domain_nameservers_form(self):
"""Can change domain's nameservers. """Can change domain's nameservers.

View file

@ -16,6 +16,7 @@ from django.views.generic.edit import FormMixin
from registrar.models import ( from registrar.models import (
Domain, Domain,
DomainInformation,
DomainInvitation, DomainInvitation,
User, User,
UserDomainRole, UserDomainRole,
@ -335,6 +336,11 @@ class DomainAddUserView(DomainPermissionView, FormMixin):
) )
else: else:
# created a new invitation in the database, so send an email # created a new invitation in the database, so send an email
domaininfo = DomainInformation.objects.filter(domain=self.object)
first = domaininfo.first().creator.first_name
last = domaininfo.first().creator.last_name
full_name = f"{first} {last}"
try: try:
send_templated_email( send_templated_email(
"emails/domain_invitation.txt", "emails/domain_invitation.txt",
@ -343,6 +349,7 @@ class DomainAddUserView(DomainPermissionView, FormMixin):
context={ context={
"domain_url": self._domain_abs_url(), "domain_url": self._domain_abs_url(),
"domain": self.object, "domain": self.object,
"full_name": full_name,
}, },
) )
except EmailSendingError: except EmailSendingError:

View file

@ -19,7 +19,7 @@ def index(request):
pk=F("domain__id"), pk=F("domain__id"),
name=F("domain__name"), name=F("domain__name"),
created_time=F("domain__created_at"), created_time=F("domain__created_at"),
application_status=F("domain__domain_application__status"), state=F("domain__state"),
) )
context["domains"] = domains context["domains"] = domains
return render(request, "home.html", context) return render(request, "home.html", context)