mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-03 09:43:33 +02:00
Merge branch 'main' into nmb/trim-field-labels
This commit is contained in:
commit
c6d15cff20
15 changed files with 140 additions and 20 deletions
37
docs/architecture/decisions/0018-registry-integration.md
Normal file
37
docs/architecture/decisions/0018-registry-integration.md
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# 18. Registry Integration
|
||||||
|
|
||||||
|
Date: 2022-02-15
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
There are relatively few existing open source software projects which implement registry-registrar communications and even fewer of them in Python.
|
||||||
|
|
||||||
|
This creates a twofold problem: first, there are few design patterns which we can consult to determine how to build; second, there are few libraries we can freely use.
|
||||||
|
|
||||||
|
The incoming registry vendor has pointed to [FRED’s epplib](https://gitlab.nic.cz/fred/utils/epplib) as a newly-developed example which may suit most of our needs. This library is able to establish the TCP connection. It also contains a number of helper methods for preparing the XML requests and parsing the XML responses.
|
||||||
|
|
||||||
|
Commands in the EPP protocol are not synchronous, meaning that the response to a command will acknowledge receipt of it, but may not indicate success or failure.
|
||||||
|
|
||||||
|
This creates an additional challenge: we do not desire to have complex background jobs to run polling. The registrar does not anticipate having a volume of daily users to make such an investment worthwhile, nor a supply of system administrators to monitor and troubleshoot such a system.
|
||||||
|
|
||||||
|
Beyond these mechanical requirements, we also need a firm understanding of the rules governing how and when commands can be issued to the registry.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
To use the open source FRED epplib developed by the .cz registry.
|
||||||
|
|
||||||
|
To treat commands given to the registry as asynchronous from a user experience perspective. In other words, “the registry has received your request, please check back later”.
|
||||||
|
|
||||||
|
To develop the Domain model as the interface to epplib.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Using the Domain model as an interface will funnel interactions with the registry and consolidate rules in a single location. This will be a significant benefit to future maintainers, but it does stretch the normal metaphor of a Django model as representing a database table. This may introduce some confusion or uncertainty.
|
||||||
|
|
||||||
|
Treating commands as asynchronous will need support from content managers and user interface designers to help registrants and analysts understand the system’s behavior. Without adequate support, users will experience surprise and frustration.
|
||||||
|
|
||||||
|
FRED epplib is in early active development. It may not contain all of the features we’d like. Limitations in what upstream maintainers are able to accept, either due to policy or due to staffing or due to lack of interest, may require CISA to fork the project. This will incur a maintenance burden on CISA.
|
|
@ -105,7 +105,7 @@ $letter-space--xs: .0125em;
|
||||||
color: color('violet-70v'); //USWDS default
|
color: color('violet-70v'); //USWDS default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.register-form-step .usa-form-group:first-of-type,
|
||||||
.register-form-step .usa-label:first-of-type {
|
.register-form-step .usa-label:first-of-type {
|
||||||
margin-top: units(1);
|
margin-top: units(1);
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,7 @@ for step, view in [
|
||||||
(Step.PURPOSE, views.Purpose),
|
(Step.PURPOSE, views.Purpose),
|
||||||
(Step.YOUR_CONTACT, views.YourContact),
|
(Step.YOUR_CONTACT, views.YourContact),
|
||||||
(Step.OTHER_CONTACTS, views.OtherContacts),
|
(Step.OTHER_CONTACTS, views.OtherContacts),
|
||||||
|
(Step.NO_OTHER_CONTACTS, views.NoOtherContacts),
|
||||||
(Step.SECURITY_EMAIL, views.SecurityEmail),
|
(Step.SECURITY_EMAIL, views.SecurityEmail),
|
||||||
(Step.ANYTHING_ELSE, views.AnythingElse),
|
(Step.ANYTHING_ELSE, views.AnythingElse),
|
||||||
(Step.REQUIREMENTS, views.Requirements),
|
(Step.REQUIREMENTS, views.Requirements),
|
||||||
|
|
|
@ -662,11 +662,11 @@ OtherContactsFormSet = forms.formset_factory(
|
||||||
|
|
||||||
class NoOtherContactsForm(RegistrarForm):
|
class NoOtherContactsForm(RegistrarForm):
|
||||||
no_other_contacts_rationale = forms.CharField(
|
no_other_contacts_rationale = forms.CharField(
|
||||||
required=False,
|
required=True,
|
||||||
# label has to end in a space to get the label_suffix to show
|
# label has to end in a space to get the label_suffix to show
|
||||||
label=(
|
label=(
|
||||||
"If you can’t provide other contacts for your organization,"
|
"Please explain why there are no other employees from your organization"
|
||||||
" please explain why."
|
" that we can contact."
|
||||||
),
|
),
|
||||||
widget=forms.Textarea(),
|
widget=forms.Textarea(),
|
||||||
)
|
)
|
||||||
|
|
|
@ -545,6 +545,10 @@ class DomainApplication(TimeStampedModel):
|
||||||
DomainApplication.OrganizationChoices.INTERSTATE,
|
DomainApplication.OrganizationChoices.INTERSTATE,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def show_no_other_contacts_rationale(self) -> bool:
|
||||||
|
"""Show this step if the other contacts are blank."""
|
||||||
|
return not self.other_contacts.exists()
|
||||||
|
|
||||||
def is_federal(self) -> Union[bool, None]:
|
def is_federal(self) -> Union[bool, None]:
|
||||||
"""Is this application for a federal agency?
|
"""Is this application for a federal agency?
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,10 @@
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% block form_messages %}
|
||||||
|
{% include "includes/form_messages.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block form_errors %}
|
{% block form_errors %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
to make sense of this loop, consider that
|
to make sense of this loop, consider that
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends 'application_form.html' %}
|
||||||
|
{% load static field_helpers %}
|
||||||
|
|
||||||
|
{% block form_fields %}
|
||||||
|
{% input_with_errors forms.0.no_other_contacts_rationale %}
|
||||||
|
{% endblock %}
|
|
@ -2,7 +2,7 @@
|
||||||
{% load field_helpers %}
|
{% load field_helpers %}
|
||||||
|
|
||||||
{% block form_instructions %}
|
{% block form_instructions %}
|
||||||
<h2 class="margin-bottom-5">
|
<h2 class="margin-bottom-05">
|
||||||
Which federal branch is your organization in?
|
Which federal branch is your organization in?
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{% load static field_helpers %}
|
{% load static field_helpers %}
|
||||||
|
|
||||||
{% block form_instructions %}
|
{% block form_instructions %}
|
||||||
<p>We’d like to contact other employees with administrative or technical responsibilities in your organization. For example, they could be involved in managing your organization or its technical infrastructure. This information will help us assess your eligibility and understand the purpose of the .gov domain. These contacts should be in addition to you and your authorizing official. They should be employees of your organization.</p>
|
<p>We’d like to contact other employees in your organization about your domain request. For example, they could be involved in managing your organization or its technical infrastructure. <strong>This information will help us assess your eligibility for a .gov domain.</strong> These contacts should be in addition to you and your authorizing official. They should be employees of your organization.</p>
|
||||||
|
|
||||||
<p>We’ll email these contacts to let them know that you made this request.</p>
|
<p>We’ll email these contacts to let them know that you made this request.</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -14,7 +14,7 @@
|
||||||
{% for form in forms.0.forms %}
|
{% for form in forms.0.forms %}
|
||||||
<fieldset class="usa-fieldset">
|
<fieldset class="usa-fieldset">
|
||||||
<legend>
|
<legend>
|
||||||
<h2>Administrative or technical contact {{ forloop.counter }}</h2>
|
<h2>Organization contact {{ forloop.counter }}</h2>
|
||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
{% input_with_errors form.first_name %}
|
{% input_with_errors form.first_name %}
|
||||||
|
@ -39,8 +39,4 @@
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
||||||
</svg><span class="margin-left-05">Add another contact</span>
|
</svg><span class="margin-left-05">Add another contact</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h2>No contacts</h2>
|
|
||||||
|
|
||||||
{% input_with_errors forms.1.no_other_contacts_rationale %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -36,7 +36,9 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if step == Step.AUTHORIZING_OFFICIAL %}
|
{% if step == Step.AUTHORIZING_OFFICIAL %}
|
||||||
{% if application.authorizing_official %}
|
{% if application.authorizing_official %}
|
||||||
|
<div class="margin-bottom-105">
|
||||||
{% include "includes/contact.html" with contact=application.authorizing_official %}
|
{% include "includes/contact.html" with contact=application.authorizing_official %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
Incomplete
|
Incomplete
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -51,8 +53,10 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if step == Step.DOTGOV_DOMAIN %}
|
{% if step == Step.DOTGOV_DOMAIN %}
|
||||||
<ul class="add-list-reset">
|
<ul class="add-list-reset margin-bottom-105">
|
||||||
<li>{{ application.requested_domain.name|default:"Incomplete" }}</li>
|
<li>{{ application.requested_domain.name|default:"Incomplete" }}</li>
|
||||||
|
</ul>
|
||||||
|
<ul class="add-list-reset">
|
||||||
{% for site in application.alternative_domains.all %}
|
{% for site in application.alternative_domains.all %}
|
||||||
<li>{{ site.website }}</li>
|
<li>{{ site.website }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -63,18 +67,25 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if step == Step.YOUR_CONTACT %}
|
{% if step == Step.YOUR_CONTACT %}
|
||||||
{% if application.submitter %}
|
{% if application.submitter %}
|
||||||
|
<div class="margin-bottom-105">
|
||||||
{% include "includes/contact.html" with contact=application.submitter %}
|
{% include "includes/contact.html" with contact=application.submitter %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
Incomplete
|
Incomplete
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if step == Step.OTHER_CONTACTS %}
|
{% if step == Step.OTHER_CONTACTS %}
|
||||||
{% for other in application.other_contacts.all %}
|
{% for other in application.other_contacts.all %}
|
||||||
|
<div class="margin-bottom-105">
|
||||||
{% include "includes/contact.html" with contact=other %}
|
{% include "includes/contact.html" with contact=other %}
|
||||||
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
None
|
None
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if step == Step.NO_OTHER_CONTACTS %}
|
||||||
|
{{ application.no_other_contacts_rationale|default:"Incomplete" }}
|
||||||
|
{% endif %}
|
||||||
{% if step == Step.SECURITY_EMAIL %}
|
{% if step == Step.SECURITY_EMAIL %}
|
||||||
{{ application.security_email|default:"None" }}
|
{{ application.security_email|default:"None" }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -82,7 +93,7 @@
|
||||||
{{ application.anything_else|default:"No" }}
|
{{ application.anything_else|default:"No" }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if step == Step.REQUIREMENTS %}
|
{% if step == Step.REQUIREMENTS %}
|
||||||
{{ application.is_policy_acknowledged|yesno:"Agree,Do not agree,Do not agree" }}
|
{{ application.is_policy_acknowledged|yesno:"I agree.,I do not agree.,I do not agree." }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -167,9 +167,11 @@
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<ul class="messages">
|
<ul class="messages">
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
|
{% if 'base' in message.extra_tags %}
|
||||||
<li{% if message.tags %} class="{{ message.tags }}" {% endif %}>
|
<li{% if message.tags %} class="{{ message.tags }}" {% endif %}>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
|
{% comment %}
|
||||||
|
Commenting the code below to turn off the error because
|
||||||
|
we are showing the caution dialog instead. But saving in
|
||||||
|
case we want to revert this.
|
||||||
{% if form.errors %}
|
{% if form.errors %}
|
||||||
{% for error in form.non_field_errors %}
|
{% for error in form.non_field_errors %}
|
||||||
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2">
|
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2">
|
||||||
<div class="usa-alert__body">
|
<div class="usa-alert__body">
|
||||||
{{ error|escape }}
|
{{ error|escape }}
|
||||||
|
@ -16,3 +20,4 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endcomment %}
|
10
src/registrar/templates/includes/form_messages.html
Normal file
10
src/registrar/templates/includes/form_messages.html
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2">
|
||||||
|
<div class="usa-alert__body">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
|
@ -124,7 +124,8 @@ class DomainApplicationTests(TestWithUser, WebTest):
|
||||||
this test work.
|
this test work.
|
||||||
"""
|
"""
|
||||||
num_pages_tested = 0
|
num_pages_tested = 0
|
||||||
SKIPPED_PAGES = 3 # elections, type_of_work, tribal_government
|
# elections, type_of_work, tribal_government, no_other_contacts
|
||||||
|
SKIPPED_PAGES = 4
|
||||||
num_pages = len(self.TITLES) - SKIPPED_PAGES
|
num_pages = len(self.TITLES) - SKIPPED_PAGES
|
||||||
|
|
||||||
type_page = self.app.get(reverse("application:")).follow()
|
type_page = self.app.get(reverse("application:")).follow()
|
||||||
|
@ -724,6 +725,24 @@ class DomainApplicationTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
self.assertContains(contact_page, self.TITLES[Step.TYPE_OF_WORK])
|
self.assertContains(contact_page, self.TITLES[Step.TYPE_OF_WORK])
|
||||||
|
|
||||||
|
def test_application_no_other_contacts(self):
|
||||||
|
"""Applicants with no other contacts have to give a reason."""
|
||||||
|
contacts_page = self.app.get(reverse("application:other_contacts"))
|
||||||
|
# django-webtest does not handle cookie-based sessions well because it keeps
|
||||||
|
# resetting the session key on each new request, thus destroying the concept
|
||||||
|
# of a "session". We are going to do it manually, saving the session ID here
|
||||||
|
# and then setting the cookie on each request.
|
||||||
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
|
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
result = contacts_page.form.submit()
|
||||||
|
# follow first redirect
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
no_contacts_page = result.follow()
|
||||||
|
expected_url_slug = str(Step.NO_OTHER_CONTACTS)
|
||||||
|
actual_url_slug = no_contacts_page.request.path.split("/")[-2]
|
||||||
|
self.assertEqual(expected_url_slug, actual_url_slug)
|
||||||
|
|
||||||
def test_application_type_of_work_interstate(self):
|
def test_application_type_of_work_interstate(self):
|
||||||
"""Special districts have to answer an additional question."""
|
"""Special districts have to answer an additional question."""
|
||||||
type_page = self.app.get(reverse("application:")).follow()
|
type_page = self.app.get(reverse("application:")).follow()
|
||||||
|
|
|
@ -6,6 +6,8 @@ from django.shortcuts import redirect, render
|
||||||
from django.urls import resolve, reverse
|
from django.urls import resolve, reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from registrar.forms import application_wizard as forms
|
from registrar.forms import application_wizard as forms
|
||||||
from registrar.models import DomainApplication
|
from registrar.models import DomainApplication
|
||||||
|
@ -35,6 +37,7 @@ class Step(StrEnum):
|
||||||
PURPOSE = "purpose"
|
PURPOSE = "purpose"
|
||||||
YOUR_CONTACT = "your_contact"
|
YOUR_CONTACT = "your_contact"
|
||||||
OTHER_CONTACTS = "other_contacts"
|
OTHER_CONTACTS = "other_contacts"
|
||||||
|
NO_OTHER_CONTACTS = "no_other_contacts"
|
||||||
SECURITY_EMAIL = "security_email"
|
SECURITY_EMAIL = "security_email"
|
||||||
ANYTHING_ELSE = "anything_else"
|
ANYTHING_ELSE = "anything_else"
|
||||||
REQUIREMENTS = "requirements"
|
REQUIREMENTS = "requirements"
|
||||||
|
@ -79,7 +82,8 @@ class ApplicationWizard(LoginRequiredMixin, TemplateView):
|
||||||
Step.DOTGOV_DOMAIN: _(".gov domain"),
|
Step.DOTGOV_DOMAIN: _(".gov domain"),
|
||||||
Step.PURPOSE: _("Purpose of your domain"),
|
Step.PURPOSE: _("Purpose of your domain"),
|
||||||
Step.YOUR_CONTACT: _("Your contact information"),
|
Step.YOUR_CONTACT: _("Your contact information"),
|
||||||
Step.OTHER_CONTACTS: _("Other contacts for your organization"),
|
Step.OTHER_CONTACTS: _("Other employees from your organization"),
|
||||||
|
Step.NO_OTHER_CONTACTS: _("No other employees from your organization?"),
|
||||||
Step.SECURITY_EMAIL: _("Security email for public use"),
|
Step.SECURITY_EMAIL: _("Security email for public use"),
|
||||||
Step.ANYTHING_ELSE: _("Anything else we should know?"),
|
Step.ANYTHING_ELSE: _("Anything else we should know?"),
|
||||||
Step.REQUIREMENTS: _(
|
Step.REQUIREMENTS: _(
|
||||||
|
@ -99,6 +103,9 @@ class ApplicationWizard(LoginRequiredMixin, TemplateView):
|
||||||
"show_organization_election", False
|
"show_organization_election", False
|
||||||
),
|
),
|
||||||
Step.TYPE_OF_WORK: lambda w: w.from_model("show_type_of_work", False),
|
Step.TYPE_OF_WORK: lambda w: w.from_model("show_type_of_work", False),
|
||||||
|
Step.NO_OTHER_CONTACTS: lambda w: w.from_model(
|
||||||
|
"show_no_other_contacts_rationale", False
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
@ -319,6 +326,18 @@ class ApplicationWizard(LoginRequiredMixin, TemplateView):
|
||||||
self.save(forms)
|
self.save(forms)
|
||||||
else:
|
else:
|
||||||
# unless there are errors
|
# unless there are errors
|
||||||
|
# no sec because this use of mark_safe does not introduce a cross-site
|
||||||
|
# scripting vulnerability because there is no untrusted content inside.
|
||||||
|
# It is only being used to pass a specific HTML entity into a template.
|
||||||
|
messages.warning(
|
||||||
|
request,
|
||||||
|
mark_safe( # nosec
|
||||||
|
"<b>We could not save all the fields.</b><br/> The highlighted "
|
||||||
|
+ "fields below <b>could not be saved</b> because they have "
|
||||||
|
+ "missing or invalid data. All other information on this page "
|
||||||
|
+ "has been saved."
|
||||||
|
),
|
||||||
|
)
|
||||||
context = self.get_context_data()
|
context = self.get_context_data()
|
||||||
context["forms"] = forms
|
context["forms"] = forms
|
||||||
return render(request, self.template_name, context)
|
return render(request, self.template_name, context)
|
||||||
|
@ -326,6 +345,7 @@ class ApplicationWizard(LoginRequiredMixin, TemplateView):
|
||||||
# if user opted to save their progress,
|
# if user opted to save their progress,
|
||||||
# return them to the page they were already on
|
# return them to the page they were already on
|
||||||
if button == "save":
|
if button == "save":
|
||||||
|
messages.success(request, "Your progress has been saved!")
|
||||||
return self.goto(self.steps.current)
|
return self.goto(self.steps.current)
|
||||||
# otherwise, proceed as normal
|
# otherwise, proceed as normal
|
||||||
return self.goto_next_step()
|
return self.goto_next_step()
|
||||||
|
@ -410,7 +430,12 @@ class YourContact(ApplicationWizard):
|
||||||
|
|
||||||
class OtherContacts(ApplicationWizard):
|
class OtherContacts(ApplicationWizard):
|
||||||
template_name = "application_other_contacts.html"
|
template_name = "application_other_contacts.html"
|
||||||
forms = [forms.OtherContactsFormSet, forms.NoOtherContactsForm]
|
forms = [forms.OtherContactsFormSet]
|
||||||
|
|
||||||
|
|
||||||
|
class NoOtherContacts(ApplicationWizard):
|
||||||
|
template_name = "application_no_other_contacts.html"
|
||||||
|
forms = [forms.NoOtherContactsForm]
|
||||||
|
|
||||||
|
|
||||||
class SecurityEmail(ApplicationWizard):
|
class SecurityEmail(ApplicationWizard):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue