mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-18 23:44:13 +02:00
Merge pull request #1749 from cisagov/rjm/1489-reduce-emails-2
Issue 1489: Reduce email sends to first-time transition for the applicable transitions
This commit is contained in:
commit
0808a69b13
3 changed files with 211 additions and 131 deletions
|
@ -654,11 +654,15 @@ class DomainApplication(TimeStampedModel):
|
||||||
self.submission_date = timezone.now().date()
|
self.submission_date = timezone.now().date()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
self._send_status_update_email(
|
# Limit email notifications to transitions from Started and Withdrawn
|
||||||
"submission confirmation",
|
limited_statuses = [self.ApplicationStatus.STARTED, self.ApplicationStatus.WITHDRAWN]
|
||||||
"emails/submission_confirmation.txt",
|
|
||||||
"emails/submission_confirmation_subject.txt",
|
if self.status in limited_statuses:
|
||||||
)
|
self._send_status_update_email(
|
||||||
|
"submission confirmation",
|
||||||
|
"emails/submission_confirmation.txt",
|
||||||
|
"emails/submission_confirmation_subject.txt",
|
||||||
|
)
|
||||||
|
|
||||||
@transition(
|
@transition(
|
||||||
field="status",
|
field="status",
|
||||||
|
@ -764,6 +768,7 @@ class DomainApplication(TimeStampedModel):
|
||||||
)
|
)
|
||||||
def withdraw(self):
|
def withdraw(self):
|
||||||
"""Withdraw an application that has been submitted."""
|
"""Withdraw an application that has been submitted."""
|
||||||
|
|
||||||
self._send_status_update_email(
|
self._send_status_update_email(
|
||||||
"withdraw",
|
"withdraw",
|
||||||
"emails/domain_request_withdrawn.txt",
|
"emails/domain_request_withdrawn.txt",
|
||||||
|
|
|
@ -306,6 +306,7 @@ class TestDomainApplicationAdminForm(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@boto3_mocking.patching
|
||||||
class TestDomainApplicationAdmin(MockEppLib):
|
class TestDomainApplicationAdmin(MockEppLib):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
@ -411,83 +412,166 @@ class TestDomainApplicationAdmin(MockEppLib):
|
||||||
# Now let's make sure the long description does not exist
|
# Now let's make sure the long description does not exist
|
||||||
self.assertNotContains(response, "Federal: an agency of the U.S. government")
|
self.assertNotContains(response, "Federal: an agency of the U.S. government")
|
||||||
|
|
||||||
@boto3_mocking.patching
|
def transition_state_and_send_email(self, application, status):
|
||||||
|
"""Helper method for the email test cases."""
|
||||||
|
|
||||||
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
|
with less_console_noise():
|
||||||
|
# Create a mock request
|
||||||
|
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
|
||||||
|
|
||||||
|
# Modify the application's property
|
||||||
|
application.status = status
|
||||||
|
|
||||||
|
# Use the model admin's save_model method
|
||||||
|
self.admin.save_model(request, application, form=None, change=True)
|
||||||
|
|
||||||
|
def assert_email_is_accurate(self, expected_string, email_index, email_address):
|
||||||
|
"""Helper method for the email test cases.
|
||||||
|
email_index is the index of the email in mock_client."""
|
||||||
|
|
||||||
|
# Access the arguments passed to send_email
|
||||||
|
call_args = self.mock_client.EMAILS_SENT
|
||||||
|
kwargs = call_args[email_index]["kwargs"]
|
||||||
|
|
||||||
|
# Retrieve the email details from the arguments
|
||||||
|
from_email = kwargs.get("FromEmailAddress")
|
||||||
|
to_email = kwargs["Destination"]["ToAddresses"][0]
|
||||||
|
email_content = kwargs["Content"]
|
||||||
|
email_body = email_content["Simple"]["Body"]["Text"]["Data"]
|
||||||
|
|
||||||
|
# Assert or perform other checks on the email details
|
||||||
|
self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL)
|
||||||
|
self.assertEqual(to_email, email_address)
|
||||||
|
self.assertIn(expected_string, email_body)
|
||||||
|
|
||||||
def test_save_model_sends_submitted_email(self):
|
def test_save_model_sends_submitted_email(self):
|
||||||
# make sure there is no user with this email
|
"""When transitioning to submitted from started or withdrawn on a domain request,
|
||||||
|
an email is sent out.
|
||||||
|
|
||||||
|
When transitioning to submitted from dns needed or in review on a domain request,
|
||||||
|
no email is sent out."""
|
||||||
|
|
||||||
|
# Ensure there is no user with this email
|
||||||
EMAIL = "mayor@igorville.gov"
|
EMAIL = "mayor@igorville.gov"
|
||||||
User.objects.filter(email=EMAIL).delete()
|
User.objects.filter(email=EMAIL).delete()
|
||||||
|
|
||||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
# Create a sample application
|
||||||
with less_console_noise():
|
application = completed_application()
|
||||||
# Create a sample application
|
|
||||||
application = completed_application()
|
|
||||||
|
|
||||||
# Create a mock request
|
|
||||||
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
|
|
||||||
|
|
||||||
# Modify the application's property
|
|
||||||
application.status = DomainApplication.ApplicationStatus.SUBMITTED
|
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
|
||||||
self.admin.save_model(request, application, form=None, change=True)
|
|
||||||
|
|
||||||
# Access the arguments passed to send_email
|
|
||||||
call_args = self.mock_client.EMAILS_SENT
|
|
||||||
kwargs = call_args[0]["kwargs"]
|
|
||||||
|
|
||||||
# Retrieve the email details from the arguments
|
|
||||||
from_email = kwargs.get("FromEmailAddress")
|
|
||||||
to_email = kwargs["Destination"]["ToAddresses"][0]
|
|
||||||
email_content = kwargs["Content"]
|
|
||||||
email_body = email_content["Simple"]["Body"]["Text"]["Data"]
|
|
||||||
|
|
||||||
# Assert or perform other checks on the email details
|
|
||||||
expected_string = "We received your .gov domain request."
|
|
||||||
self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL)
|
|
||||||
self.assertEqual(to_email, EMAIL)
|
|
||||||
self.assertIn(expected_string, email_body)
|
|
||||||
|
|
||||||
|
# Test Submitted Status from started
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
|
||||||
|
self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||||
|
|
||||||
@boto3_mocking.patching
|
# Test Withdrawn Status
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.WITHDRAWN)
|
||||||
|
self.assert_email_is_accurate(
|
||||||
|
"Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL
|
||||||
|
)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||||
|
|
||||||
|
# Test Submitted Status Again (from withdrawn)
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
|
# Move it to IN_REVIEW
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
|
# Test Submitted Status Again from in IN_REVIEW, no new email should be sent
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
|
# Move it to IN_REVIEW
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
|
# Move it to ACTION_NEEDED
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.ACTION_NEEDED)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
|
# Test Submitted Status Again from in ACTION_NEEDED, no new email should be sent
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
def test_save_model_sends_approved_email(self):
|
def test_save_model_sends_approved_email(self):
|
||||||
# make sure there is no user with this email
|
"""When transitioning to approved on a domain request,
|
||||||
|
an email is sent out every time."""
|
||||||
|
|
||||||
|
# Ensure there is no user with this email
|
||||||
EMAIL = "mayor@igorville.gov"
|
EMAIL = "mayor@igorville.gov"
|
||||||
User.objects.filter(email=EMAIL).delete()
|
User.objects.filter(email=EMAIL).delete()
|
||||||
|
|
||||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
# Create a sample application
|
||||||
with less_console_noise():
|
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||||
# Create a sample application
|
|
||||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
|
||||||
|
|
||||||
# Create a mock request
|
|
||||||
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
|
|
||||||
|
|
||||||
# Modify the application's property
|
|
||||||
application.status = DomainApplication.ApplicationStatus.APPROVED
|
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
|
||||||
self.admin.save_model(request, application, form=None, change=True)
|
|
||||||
|
|
||||||
# Access the arguments passed to send_email
|
|
||||||
call_args = self.mock_client.EMAILS_SENT
|
|
||||||
kwargs = call_args[0]["kwargs"]
|
|
||||||
|
|
||||||
# Retrieve the email details from the arguments
|
|
||||||
from_email = kwargs.get("FromEmailAddress")
|
|
||||||
to_email = kwargs["Destination"]["ToAddresses"][0]
|
|
||||||
email_content = kwargs["Content"]
|
|
||||||
email_body = email_content["Simple"]["Body"]["Text"]["Data"]
|
|
||||||
|
|
||||||
# Assert or perform other checks on the email details
|
|
||||||
expected_string = "Congratulations! Your .gov domain request has been approved."
|
|
||||||
self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL)
|
|
||||||
self.assertEqual(to_email, EMAIL)
|
|
||||||
self.assertIn(expected_string, email_body)
|
|
||||||
|
|
||||||
|
# Test Submitted Status
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||||
|
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 0, EMAIL)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||||
|
|
||||||
@boto3_mocking.patching
|
# Test Withdrawn Status
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.REJECTED)
|
||||||
|
self.assert_email_is_accurate("Your .gov domain request has been rejected.", 1, EMAIL)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||||
|
|
||||||
|
# Test Submitted Status Again (No new email should be sent)
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
|
def test_save_model_sends_rejected_email(self):
|
||||||
|
"""When transitioning to rejected on a domain request,
|
||||||
|
an email is sent out every time."""
|
||||||
|
|
||||||
|
# Ensure there is no user with this email
|
||||||
|
EMAIL = "mayor@igorville.gov"
|
||||||
|
User.objects.filter(email=EMAIL).delete()
|
||||||
|
|
||||||
|
# Create a sample application
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||||
|
|
||||||
|
# Test Submitted Status
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.REJECTED)
|
||||||
|
self.assert_email_is_accurate("Your .gov domain request has been rejected.", 0, EMAIL)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||||
|
|
||||||
|
# Test Withdrawn Status
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||||
|
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||||
|
|
||||||
|
# Test Submitted Status Again (No new email should be sent)
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.REJECTED)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
|
def test_save_model_sends_withdrawn_email(self):
|
||||||
|
"""When transitioning to withdrawn on a domain request,
|
||||||
|
an email is sent out every time."""
|
||||||
|
|
||||||
|
# Ensure there is no user with this email
|
||||||
|
EMAIL = "mayor@igorville.gov"
|
||||||
|
User.objects.filter(email=EMAIL).delete()
|
||||||
|
|
||||||
|
# Create a sample application
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||||
|
|
||||||
|
# Test Submitted Status
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.WITHDRAWN)
|
||||||
|
self.assert_email_is_accurate(
|
||||||
|
"Your .gov domain request has been withdrawn and will not be reviewed by our team.", 0, EMAIL
|
||||||
|
)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||||
|
|
||||||
|
# Test Withdrawn Status
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
|
||||||
|
self.assert_email_is_accurate("We received your .gov domain request.", 1, EMAIL)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||||
|
|
||||||
|
# Test Submitted Status Again (No new email should be sent)
|
||||||
|
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.WITHDRAWN)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
def test_save_model_sets_approved_domain(self):
|
def test_save_model_sets_approved_domain(self):
|
||||||
# make sure there is no user with this email
|
# make sure there is no user with this email
|
||||||
EMAIL = "mayor@igorville.gov"
|
EMAIL = "mayor@igorville.gov"
|
||||||
|
@ -510,45 +594,6 @@ class TestDomainApplicationAdmin(MockEppLib):
|
||||||
# Test that approved domain exists and equals requested domain
|
# Test that approved domain exists and equals requested domain
|
||||||
self.assertEqual(application.requested_domain.name, application.approved_domain.name)
|
self.assertEqual(application.requested_domain.name, application.approved_domain.name)
|
||||||
|
|
||||||
@boto3_mocking.patching
|
|
||||||
def test_save_model_sends_rejected_email(self):
|
|
||||||
# make sure there is no user with this email
|
|
||||||
EMAIL = "mayor@igorville.gov"
|
|
||||||
User.objects.filter(email=EMAIL).delete()
|
|
||||||
|
|
||||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
|
||||||
with less_console_noise():
|
|
||||||
# Create a sample application
|
|
||||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
|
||||||
|
|
||||||
# Create a mock request
|
|
||||||
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
|
|
||||||
|
|
||||||
# Modify the application's property
|
|
||||||
application.status = DomainApplication.ApplicationStatus.REJECTED
|
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
|
||||||
self.admin.save_model(request, application, form=None, change=True)
|
|
||||||
|
|
||||||
# Access the arguments passed to send_email
|
|
||||||
call_args = self.mock_client.EMAILS_SENT
|
|
||||||
kwargs = call_args[0]["kwargs"]
|
|
||||||
|
|
||||||
# Retrieve the email details from the arguments
|
|
||||||
from_email = kwargs.get("FromEmailAddress")
|
|
||||||
to_email = kwargs["Destination"]["ToAddresses"][0]
|
|
||||||
email_content = kwargs["Content"]
|
|
||||||
email_body = email_content["Simple"]["Body"]["Text"]["Data"]
|
|
||||||
|
|
||||||
# Assert or perform other checks on the email details
|
|
||||||
expected_string = "Your .gov domain request has been rejected."
|
|
||||||
self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL)
|
|
||||||
self.assertEqual(to_email, EMAIL)
|
|
||||||
self.assertIn(expected_string, email_body)
|
|
||||||
|
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
|
||||||
|
|
||||||
@boto3_mocking.patching
|
|
||||||
def test_save_model_sets_restricted_status_on_user(self):
|
def test_save_model_sets_restricted_status_on_user(self):
|
||||||
# make sure there is no user with this email
|
# make sure there is no user with this email
|
||||||
EMAIL = "mayor@igorville.gov"
|
EMAIL = "mayor@igorville.gov"
|
||||||
|
|
|
@ -161,33 +161,63 @@ class TestDomainApplication(TestCase):
|
||||||
application.submit()
|
application.submit()
|
||||||
self.assertEqual(application.status, application.ApplicationStatus.SUBMITTED)
|
self.assertEqual(application.status, application.ApplicationStatus.SUBMITTED)
|
||||||
|
|
||||||
def test_submit_sends_email(self):
|
def check_email_sent(self, application, msg, action, expected_count):
|
||||||
"""Create an application and submit it and see if email was sent."""
|
"""Check if an email was sent after performing an action."""
|
||||||
with less_console_noise():
|
|
||||||
user, _ = User.objects.get_or_create(username="testy")
|
|
||||||
contact = Contact.objects.create(email="test@test.gov")
|
|
||||||
domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
|
|
||||||
application = DomainApplication.objects.create(
|
|
||||||
creator=user,
|
|
||||||
requested_domain=domain,
|
|
||||||
submitter=contact,
|
|
||||||
)
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
|
with self.subTest(msg=msg, action=action):
|
||||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
application.submit()
|
with less_console_noise():
|
||||||
|
# Perform the specified action
|
||||||
|
action_method = getattr(application, action)
|
||||||
|
action_method()
|
||||||
|
|
||||||
# check to see if an email was sent
|
# Check if an email was sent
|
||||||
self.assertGreater(
|
sent_emails = [
|
||||||
len(
|
email
|
||||||
[
|
for email in MockSESClient.EMAILS_SENT
|
||||||
email
|
if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"]
|
||||||
for email in MockSESClient.EMAILS_SENT
|
]
|
||||||
if "test@test.gov" in email["kwargs"]["Destination"]["ToAddresses"]
|
self.assertEqual(len(sent_emails), expected_count)
|
||||||
]
|
|
||||||
),
|
def test_submit_from_started_sends_email(self):
|
||||||
0,
|
msg = "Create an application and submit it and see if email was sent."
|
||||||
)
|
application = completed_application()
|
||||||
|
self.check_email_sent(application, msg, "submit", 1)
|
||||||
|
|
||||||
|
def test_submit_from_withdrawn_sends_email(self):
|
||||||
|
msg = "Create a withdrawn application and submit it and see if email was sent."
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.WITHDRAWN)
|
||||||
|
self.check_email_sent(application, msg, "submit", 1)
|
||||||
|
|
||||||
|
def test_submit_from_action_needed_does_not_send_email(self):
|
||||||
|
msg = "Create an application with ACTION_NEEDED status and submit it, check if email was not sent."
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.ACTION_NEEDED)
|
||||||
|
self.check_email_sent(application, msg, "submit", 0)
|
||||||
|
|
||||||
|
def test_submit_from_in_review_does_not_send_email(self):
|
||||||
|
msg = "Create a withdrawn application and submit it and see if email was sent."
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||||
|
self.check_email_sent(application, msg, "submit", 0)
|
||||||
|
|
||||||
|
def test_approve_sends_email(self):
|
||||||
|
msg = "Create an application and approve it and see if email was sent."
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||||
|
self.check_email_sent(application, msg, "approve", 1)
|
||||||
|
|
||||||
|
def test_withdraw_sends_email(self):
|
||||||
|
msg = "Create an application and withdraw it and see if email was sent."
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||||
|
self.check_email_sent(application, msg, "withdraw", 1)
|
||||||
|
|
||||||
|
def test_reject_sends_email(self):
|
||||||
|
msg = "Create an application and reject it and see if email was sent."
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
|
||||||
|
self.check_email_sent(application, msg, "reject", 1)
|
||||||
|
|
||||||
|
def test_reject_with_prejudice_does_not_send_email(self):
|
||||||
|
msg = "Create an application and reject it with prejudice and see if email was sent."
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
|
||||||
|
self.check_email_sent(application, msg, "reject_with_prejudice", 0)
|
||||||
|
|
||||||
def test_submit_transition_allowed(self):
|
def test_submit_transition_allowed(self):
|
||||||
"""
|
"""
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue