Merge branch 'main' into za/3299-script-to-update-suborg-values

This commit is contained in:
zandercymatics 2025-01-16 10:10:26 -07:00
commit fcac8a8b7c
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
28 changed files with 2468 additions and 610 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

@ -14,6 +14,7 @@ from django.db.models import (
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from registrar.models.federal_agency import FederalAgency from registrar.models.federal_agency import FederalAgency
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.utility.admin_helpers import ( from registrar.utility.admin_helpers import (
AutocompleteSelectWithPlaceholder, AutocompleteSelectWithPlaceholder,
get_action_needed_reason_default_email, get_action_needed_reason_default_email,
@ -27,8 +28,12 @@ from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.utility.email import EmailSendingError from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
from registrar.utility.email_invitations import send_portfolio_invitation_email from registrar.views.utility.invitation_helper import (
get_org_membership,
get_requested_user,
handle_invitation_exceptions,
)
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@ -41,7 +46,7 @@ from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch from waffle.models import Sample, Switch
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes, MissingEmailError from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.utility.waffle import flag_is_active_for_user from registrar.utility.waffle import flag_is_active_for_user
from registrar.views.utility.mixins import OrderableFieldsMixin from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR from django.contrib.admin.views.main import ORDER_VAR
@ -1389,7 +1394,78 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().changeform_view(request, object_id, form_url, extra_context=extra_context) return super().changeform_view(request, object_id, form_url, extra_context=extra_context)
class DomainInvitationAdmin(ListHeaderAdmin): class BaseInvitationAdmin(ListHeaderAdmin):
"""Base class for admin classes which will customize save_model and send email invitations
on model adds, and require custom handling of forms and form errors."""
def response_add(self, request, obj, post_url_continue=None):
"""
Override response_add to handle rendering when exceptions are raised during add model.
Normal flow on successful save_model on add is to redirect to changelist_view.
If there are errors, flow is modified to instead render change form.
"""
# store current messages from request so that they are preserved throughout the method
storage = get_messages(request)
# Check if there are any error or warning messages in the `messages` framework
has_errors = any(message.level_tag in ["error", "warning"] for message in storage)
if has_errors:
# Re-render the change form if there are errors or warnings
# Prepare context for rendering the change form
# Get the model form
ModelForm = self.get_form(request, obj=obj)
form = ModelForm(instance=obj)
# Create an AdminForm instance
admin_form = AdminForm(
form,
list(self.get_fieldsets(request, obj)),
self.get_prepopulated_fields(request, obj),
self.get_readonly_fields(request, obj),
model_admin=self,
)
media = self.media + form.media
opts = obj._meta
change_form_context = {
**self.admin_site.each_context(request), # Add admin context
"title": f"Add {opts.verbose_name}",
"opts": opts,
"original": obj,
"save_as": self.save_as,
"has_change_permission": self.has_change_permission(request, obj),
"add": True, # Indicate this is an "Add" form
"change": False, # Indicate this is not a "Change" form
"is_popup": False,
"inline_admin_formsets": [],
"save_on_top": self.save_on_top,
"show_delete": self.has_delete_permission(request, obj),
"obj": obj,
"adminform": admin_form, # Pass the AdminForm instance
"media": media,
"errors": None,
}
return self.render_change_form(
request,
context=change_form_context,
add=True,
change=False,
obj=obj,
)
response = super().response_add(request, obj, post_url_continue)
# Re-add all messages from storage after `super().response_add`
# as super().response_add resets the success messages in request
for message in storage:
messages.add_message(request, message.level, message.message)
return response
class DomainInvitationAdmin(BaseInvitationAdmin):
"""Custom domain invitation admin class.""" """Custom domain invitation admin class."""
class Meta: class Meta:
@ -1442,14 +1518,60 @@ class DomainInvitationAdmin(ListHeaderAdmin):
which will be successful if a single User exists for that email; otherwise, will which will be successful if a single User exists for that email; otherwise, will
just continue to create the invitation. just continue to create the invitation.
""" """
if not change and User.objects.filter(email=obj.email).count() == 1: if not change:
domain = obj.domain
domain_org = getattr(domain.domain_info, "portfolio", None)
requested_email = obj.email
# Look up a user with that email
requested_user = get_requested_user(requested_email)
requestor = request.user
member_of_a_different_org, member_of_this_org = get_org_membership(
domain_org, requested_email, requested_user
)
try:
if (
flag_is_active(request, "organization_feature")
and not flag_is_active(request, "multiple_portfolios")
and domain_org is not None
and not member_of_this_org
and not member_of_a_different_org
):
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
email=requested_email,
portfolio=domain_org,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# if user exists for email, immediately retrieve portfolio invitation upon creation
if requested_user is not None:
portfolio_invitation.retrieve()
portfolio_invitation.save()
messages.success(request, f"{requested_email} has been invited to the organization: {domain_org}")
send_domain_invitation_email(
email=requested_email,
requestor=requestor,
domains=domain,
is_member_of_different_org=member_of_a_different_org,
requested_user=requested_user,
)
if requested_user is not None:
# Domain Invitation creation for an existing User # Domain Invitation creation for an existing User
obj.retrieve() obj.retrieve()
# Call the parent save method to save the object # Call the parent save method to save the object
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
messages.success(request, f"{requested_email} has been invited to the domain: {domain}")
except Exception as e:
handle_invitation_exceptions(request, e, requested_email)
return
else:
# Call the parent save method to save the object
super().save_model(request, obj, form, change)
class PortfolioInvitationAdmin(ListHeaderAdmin): class PortfolioInvitationAdmin(BaseInvitationAdmin):
"""Custom portfolio invitation admin class.""" """Custom portfolio invitation admin class."""
form = PortfolioInvitationAdminForm form = PortfolioInvitationAdminForm
@ -1472,7 +1594,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
# Search # Search
search_fields = [ search_fields = [
"email", "email",
"portfolio__name", "portfolio__organization_name",
] ]
# Filters # Filters
@ -1510,6 +1632,8 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
portfolio = obj.portfolio portfolio = obj.portfolio
requested_email = obj.email requested_email = obj.email
requestor = request.user requestor = request.user
# Look up a user with that email
requested_user = get_requested_user(requested_email)
permission_exists = UserPortfolioPermission.objects.filter( permission_exists = UserPortfolioPermission.objects.filter(
user__email=requested_email, portfolio=portfolio, user__email__isnull=False user__email=requested_email, portfolio=portfolio, user__email__isnull=False
@ -1518,98 +1642,19 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
if not permission_exists: if not permission_exists:
# if permission does not exist for a user with requested_email, send email # if permission does not exist for a user with requested_email, send email
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
# if user exists for email, immediately retrieve portfolio invitation upon creation
if requested_user is not None:
obj.retrieve()
messages.success(request, f"{requested_email} has been invited.") messages.success(request, f"{requested_email} has been invited.")
else: else:
messages.warning(request, "User is already a member of this portfolio.") messages.warning(request, "User is already a member of this portfolio.")
except Exception as e: except Exception as e:
# when exception is raised, handle and do not save the model # when exception is raised, handle and do not save the model
self._handle_exceptions(e, request, obj) handle_invitation_exceptions(request, e, requested_email)
return return
# Call the parent save method to save the object # Call the parent save method to save the object
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
def _handle_exceptions(self, exception, request, obj):
"""Handle exceptions raised during the process.
Log warnings / errors, and message errors to the user.
"""
if isinstance(exception, EmailSendingError):
logger.warning(
"Could not sent email invitation to %s for portfolio %s (EmailSendingError)",
obj.email,
obj.portfolio,
exc_info=True,
)
messages.error(request, "Could not send email invitation. Portfolio invitation not saved.")
elif isinstance(exception, MissingEmailError):
messages.error(request, str(exception))
logger.error(
f"Can't send email to '{obj.email}' for portfolio '{obj.portfolio}'. "
f"No email exists for the requestor.",
exc_info=True,
)
else:
logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.error(request, "Could not send email invitation. Portfolio invitation not saved.")
def response_add(self, request, obj, post_url_continue=None):
"""
Override response_add to handle rendering when exceptions are raised during add model.
Normal flow on successful save_model on add is to redirect to changelist_view.
If there are errors, flow is modified to instead render change form.
"""
# Check if there are any error or warning messages in the `messages` framework
storage = get_messages(request)
has_errors = any(message.level_tag in ["error", "warning"] for message in storage)
if has_errors:
# Re-render the change form if there are errors or warnings
# Prepare context for rendering the change form
# Get the model form
ModelForm = self.get_form(request, obj=obj)
form = ModelForm(instance=obj)
# Create an AdminForm instance
admin_form = AdminForm(
form,
list(self.get_fieldsets(request, obj)),
self.get_prepopulated_fields(request, obj),
self.get_readonly_fields(request, obj),
model_admin=self,
)
media = self.media + form.media
opts = obj._meta
change_form_context = {
**self.admin_site.each_context(request), # Add admin context
"title": f"Add {opts.verbose_name}",
"opts": opts,
"original": obj,
"save_as": self.save_as,
"has_change_permission": self.has_change_permission(request, obj),
"add": True, # Indicate this is an "Add" form
"change": False, # Indicate this is not a "Change" form
"is_popup": False,
"inline_admin_formsets": [],
"save_on_top": self.save_on_top,
"show_delete": self.has_delete_permission(request, obj),
"obj": obj,
"adminform": admin_form, # Pass the AdminForm instance
"media": media,
"errors": None,
}
return self.render_change_form(
request,
context=change_form_context,
add=True,
change=False,
obj=obj,
)
return super().response_add(request, obj, post_url_continue)
class DomainInformationResource(resources.ModelResource): class DomainInformationResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -2782,7 +2827,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
try: try:
# Retrieve and order audit log entries by timestamp in descending order # Retrieve and order audit log entries by timestamp in descending order
audit_log_entries = LogEntry.objects.filter(object_id=object_id).order_by("-timestamp") audit_log_entries = LogEntry.objects.filter(
object_id=object_id, content_type__model="domainrequest"
).order_by("-timestamp")
# Process each log entry to filter based on the change criteria # Process each log entry to filter based on the change criteria
for log_entry in audit_log_entries: for log_entry in audit_log_entries:

View file

@ -66,9 +66,9 @@
text-align: center; text-align: center;
font-size: inherit; //inherit tooltip fontsize of .93rem font-size: inherit; //inherit tooltip fontsize of .93rem
max-width: fit-content; max-width: fit-content;
display: block;
@include at-media('desktop') { @include at-media('desktop') {
width: 70vw; width: 70vw;
} }
display: block;
} }
} }

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

@ -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

@ -153,7 +153,9 @@ def validate_user_portfolio_permission(user_portfolio_permission):
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios." "Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
) )
existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email) existing_invitations = PortfolioInvitation.objects.exclude(
portfolio=user_portfolio_permission.portfolio
).filter(email=user_portfolio_permission.user.email)
if existing_invitations.exists(): if existing_invitations.exists():
raise ValidationError( raise ValidationError(
"This user is already assigned to a portfolio invitation. " "This user is already assigned to a portfolio invitation. "

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 %}

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,6 +80,15 @@
{% 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>

View file

@ -1,36 +1,40 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} {% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi. Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
{{ requestor_email }} has added you as a manager on {{ domain.name }}. {{ requestor_email }} has invited you to manage:
{% for domain in domains %}{{ domain.name }}
You can manage this domain on the .gov registrar <https://manage.get.gov>. {% endfor %}
To manage domain information, visit the .gov registrar <https://manage.get.gov>.
---------------------------------------------------------------- ----------------------------------------------------------------
{% if not requested_user %}
YOU NEED A LOGIN.GOV ACCOUNT YOU NEED A LOGIN.GOV ACCOUNT
Youll need a Login.gov account to manage your .gov domain. Login.gov provides Youll need a Login.gov account to access the .gov registrar. That account needs to be
a simple and secure process for signing in to many government services with one associated with the following email address: {{ invitee_email_address }}
account.
If you dont already have one, follow these steps to create your Login.gov provides a simple and secure process for signing in to many government
Login.gov account <https://login.gov/help/get-started/create-your-account/>. 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/>.
{% endif %}
DOMAIN MANAGEMENT DOMAIN MANAGEMENT
As a .gov domain manager, you can add or update information about your domain. As a .gov domain manager, you can add or update information like name servers. Youll
Youll also serve as a contact for your .gov domain. Please keep your contact also serve as a contact for the domains you manage. Please keep your contact
information updated. information updated.
Learn more about domain management <https://get.gov/help/domain-management>. Learn more about domain management <https://get.gov/help/domain-management>.
SOMETHING WRONG? SOMETHING WRONG?
If youre not affiliated with {{ domain.name }} or think you received this If youre not affiliated with the .gov domains mentioned in this invitation or think you
message in error, reply to this email. received this message in error, reply to this email.
THANK YOU THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain. .Gov helps the public identify official, trusted information. Thank you for using a .gov
domain.
---------------------------------------------------------------- ----------------------------------------------------------------
@ -38,5 +42,6 @@ The .gov team
Contact us: <https://get.gov/contact/> Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov> Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/> The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
(CISA) <https://cisa.gov/>
{% endautoescape %} {% endautoescape %}

View file

@ -1 +1 @@
Youve been added to a .gov domain You've been invited to manage {% if domains|length > 1 %}.gov domains{% else %}{{ domains.0.name }}{% endif %}

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

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@ from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
from .common import ( from .common import (
MockSESClient, MockSESClient,
completed_domain_request, completed_domain_request,
create_superuser,
create_test_user, create_test_user,
) )
from waffle.testutils import override_flag from waffle.testutils import override_flag
@ -155,6 +156,7 @@ class TestPortfolioInvitations(TestCase):
roles=[self.portfolio_role_base, self.portfolio_role_admin], roles=[self.portfolio_role_base, self.portfolio_role_admin],
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
) )
self.superuser = create_superuser()
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
@ -294,10 +296,158 @@ class TestPortfolioInvitations(TestCase):
# Verify # Verify
self.assertEquals(self.invitation.get_portfolio_permissions(), perm_list) self.assertEquals(self.invitation.get_portfolio_permissions(), perm_list)
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=False)
def test_clean_multiple_portfolios_inactive(self):
"""Tests that users cannot have multiple portfolios or invitations when flag is inactive"""
# Create the first portfolio permission
UserPortfolioPermission.objects.create(
user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Test a second portfolio permission object (should fail)
second_portfolio = Portfolio.objects.create(organization_name="Second Portfolio", creator=self.superuser)
second_permission = UserPortfolioPermission(
user=self.superuser, portfolio=second_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
with self.assertRaises(ValidationError) as err:
second_permission.clean()
self.assertIn("users cannot be assigned to multiple portfolios", str(err.exception))
# Test that adding a new portfolio invitation also fails
third_portfolio = Portfolio.objects.create(organization_name="Third Portfolio", creator=self.superuser)
invitation = PortfolioInvitation(
email=self.superuser.email, portfolio=third_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
with self.assertRaises(ValidationError) as err:
invitation.clean()
self.assertIn("users cannot be assigned to multiple portfolios", str(err.exception))
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=True)
def test_clean_multiple_portfolios_active(self):
"""Tests that users can have multiple portfolios and invitations when flag is active"""
# Create first portfolio permission
UserPortfolioPermission.objects.create(
user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Second portfolio permission should succeed
second_portfolio = Portfolio.objects.create(organization_name="Second Portfolio", creator=self.superuser)
second_permission = UserPortfolioPermission(
user=self.superuser, portfolio=second_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
second_permission.clean()
second_permission.save()
# Verify both permissions exist
user_permissions = UserPortfolioPermission.objects.filter(user=self.superuser)
self.assertEqual(user_permissions.count(), 2)
# Portfolio invitation should also succeed
third_portfolio = Portfolio.objects.create(organization_name="Third Portfolio", creator=self.superuser)
invitation = PortfolioInvitation(
email=self.superuser.email, portfolio=third_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
invitation.clean()
invitation.save()
# Verify invitation exists
self.assertTrue(
PortfolioInvitation.objects.filter(
email=self.superuser.email,
portfolio=third_portfolio,
).exists()
)
@less_console_noise_decorator
def test_clean_portfolio_invitation(self):
"""Tests validation of portfolio invitation permissions"""
# Test validation fails when portfolio missing but permissions present
invitation = PortfolioInvitation(email="test@example.com", roles=["organization_admin"], portfolio=None)
with self.assertRaises(ValidationError) as err:
invitation.clean()
self.assertEqual(
str(err.exception),
"When portfolio roles or additional permissions are assigned, portfolio is required.",
)
# Test validation fails when portfolio present but no permissions
invitation = PortfolioInvitation(email="test@example.com", roles=None, portfolio=self.portfolio)
with self.assertRaises(ValidationError) as err:
invitation.clean()
self.assertEqual(
str(err.exception),
"When portfolio is assigned, portfolio roles or additional permissions are required.",
)
# Test validation fails with forbidden permissions
forbidden_member_roles = UserPortfolioPermission.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER
)
invitation = PortfolioInvitation(
email="test@example.com",
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=forbidden_member_roles,
portfolio=self.portfolio,
)
with self.assertRaises(ValidationError) as err:
invitation.clean()
self.assertEqual(
str(err.exception),
"These permissions cannot be assigned to Member: "
"<View all domains and domain reports, Create and edit members, View members>",
)
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=False)
def test_clean_user_portfolio_permission_multiple_portfolios_flag_off_and_duplicate_permission(self):
"""MISSING TEST: Test validation of multiple_portfolios flag.
Scenario 1: Flag is inactive, and the user has existing portfolio permissions
NOTE: Refer to the same test under TestUserPortfolioPermission"""
pass
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=False)
def test_clean_user_portfolio_permission_multiple_portfolios_flag_off_and_existing_invitation(self):
"""MISSING TEST: Test validation of multiple_portfolios flag.
Scenario 2: Flag is inactive, and the user has existing portfolio invitation to another portfolio
NOTE: Refer to the same test under TestUserPortfolioPermission"""
pass
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=True)
def test_clean_user_portfolio_permission_multiple_portfolios_flag_on_and_duplicate_permission(self):
"""MISSING TEST: Test validation of multiple_portfolios flag.
Scenario 3: Flag is active, and the user has existing portfolio invitation
NOTE: Refer to the same test under TestUserPortfolioPermission"""
pass
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=True)
def test_clean_user_portfolio_permission_multiple_portfolios_flag_on_and_existing_invitation(self):
"""MISSING TEST: Test validation of multiple_portfolios flag.
Scenario 4: Flag is active, and the user has existing portfolio invitation to another portfolio
NOTE: Refer to the same test under TestUserPortfolioPermission"""
pass
class TestUserPortfolioPermission(TestCase): class TestUserPortfolioPermission(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def setUp(self): def setUp(self):
self.superuser = create_superuser()
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov") self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov")
self.user2, _ = User.objects.get_or_create(email="user2@igorville.gov", username="user2") self.user2, _ = User.objects.get_or_create(email="user2@igorville.gov", username="user2")
super().setUp() super().setUp()
@ -311,6 +461,7 @@ class TestUserPortfolioPermission(TestCase):
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
PortfolioInvitation.objects.all().delete()
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("multiple_portfolios", active=True) @override_flag("multiple_portfolios", active=True)
@ -427,6 +578,178 @@ class TestUserPortfolioPermission(TestCase):
# Assert # Assert
self.assertEqual(portfolio_permission.get_managed_domains_count(), 1) self.assertEqual(portfolio_permission.get_managed_domains_count(), 1)
@less_console_noise_decorator
def test_clean_user_portfolio_permission(self):
"""Tests validation of user portfolio permission"""
# Test validation fails when portfolio missing but permissions are present
permission = UserPortfolioPermission(user=self.superuser, roles=["organization_admin"], portfolio=None)
with self.assertRaises(ValidationError) as err:
permission.clean()
self.assertEqual(
str(err.exception),
"When portfolio roles or additional permissions are assigned, portfolio is required.",
)
# Test validation fails when portfolio present but no permissions are present
permission = UserPortfolioPermission(user=self.superuser, roles=None, portfolio=self.portfolio)
with self.assertRaises(ValidationError) as err:
permission.clean()
self.assertEqual(
str(err.exception),
"When portfolio is assigned, portfolio roles or additional permissions are required.",
)
# Test validation fails with forbidden permissions for single role
forbidden_member_roles = UserPortfolioPermission.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER
)
permission = UserPortfolioPermission(
user=self.superuser,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=forbidden_member_roles,
portfolio=self.portfolio,
)
with self.assertRaises(ValidationError) as err:
permission.clean()
self.assertEqual(
str(err.exception),
"These permissions cannot be assigned to Member: "
"<Create and edit members, View all domains and domain reports, View members>",
)
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=False)
def test_clean_user_portfolio_permission_multiple_portfolios_flag_off_and_duplicate_permission(self):
"""Test validation of multiple_portfolios flag.
Scenario 1: Flag is inactive, and the user has existing portfolio permissions"""
# existing permission
UserPortfolioPermission.objects.create(
user=self.superuser,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
portfolio=self.portfolio,
)
permission = UserPortfolioPermission(
user=self.superuser,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
portfolio=self.portfolio,
)
with self.assertRaises(ValidationError) as err:
permission.clean()
self.assertEqual(
str(err.exception.messages[0]),
"This user is already assigned to a portfolio. "
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios.",
)
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=False)
def test_clean_user_portfolio_permission_multiple_portfolios_flag_off_and_existing_invitation(self):
"""Test validation of multiple_portfolios flag.
Scenario 2: Flag is inactive, and the user has existing portfolio invitation to another portfolio"""
portfolio2 = Portfolio.objects.create(creator=self.superuser, organization_name="Joey go away")
PortfolioInvitation.objects.create(
email=self.superuser.email, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], portfolio=portfolio2
)
permission = UserPortfolioPermission(
user=self.superuser,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
portfolio=self.portfolio,
)
with self.assertRaises(ValidationError) as err:
permission.clean()
self.assertEqual(
str(err.exception.messages[0]),
"This user is already assigned to a portfolio invitation. "
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios.",
)
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=True)
def test_clean_user_portfolio_permission_multiple_portfolios_flag_on_and_duplicate_permission(self):
"""Test validation of multiple_portfolios flag.
Scenario 3: Flag is active, and the user has existing portfolio invitation"""
# existing permission
UserPortfolioPermission.objects.create(
user=self.superuser,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
portfolio=self.portfolio,
)
permission = UserPortfolioPermission(
user=self.superuser,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
portfolio=self.portfolio,
)
# Should not raise any exceptions
try:
permission.clean()
except ValidationError:
self.fail("ValidationError was raised unexpectedly when flag is active.")
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=True)
def test_clean_user_portfolio_permission_multiple_portfolios_flag_on_and_existing_invitation(self):
"""Test validation of multiple_portfolios flag.
Scenario 4: Flag is active, and the user has existing portfolio invitation to another portfolio"""
portfolio2 = Portfolio.objects.create(creator=self.superuser, organization_name="Joey go away")
PortfolioInvitation.objects.create(
email=self.superuser.email, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], portfolio=portfolio2
)
permission = UserPortfolioPermission(
user=self.superuser,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
portfolio=self.portfolio,
)
# Should not raise any exceptions
try:
permission.clean()
except ValidationError:
self.fail("ValidationError was raised unexpectedly when flag is active.")
@less_console_noise_decorator
def test_get_forbidden_permissions_with_multiple_roles(self):
"""Tests that forbidden permissions are properly handled when a user has multiple roles"""
# Get forbidden permissions for member role
member_forbidden = UserPortfolioPermission.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER
)
# Test with both admin and member roles
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN, UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
# These permissions would be forbidden for member alone, but should be allowed
# when combined with admin role
permissions = UserPortfolioPermission.get_forbidden_permissions(
roles=roles, additional_permissions=member_forbidden
)
# Should return empty set since no permissions are commonly forbidden between admin and member
self.assertEqual(permissions, set())
# Verify the same permissions are forbidden when only member role is present
member_only_permissions = UserPortfolioPermission.get_forbidden_permissions(
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], additional_permissions=member_forbidden
)
# Should return the forbidden permissions for member role
self.assertEqual(member_only_permissions, set(member_forbidden))
class TestUser(TestCase): class TestUser(TestCase):
"""Test actions that occur on user login, """Test actions that occur on user login,

View file

@ -900,6 +900,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)

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
@ -564,6 +720,8 @@ class TestDomainManagers(TestDomainOverview):
def tearDown(self): def tearDown(self):
"""Ensure that the user has its original permissions""" """Ensure that the user has its original permissions"""
PortfolioInvitation.objects.all().delete() PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
User.objects.exclude(id=self.user.id).delete()
super().tearDown() super().tearDown()
@less_console_noise_decorator @less_console_noise_decorator
@ -651,21 +809,76 @@ class TestDomainManagers(TestDomainOverview):
call_args = mock_send_domain_email.call_args.kwargs call_args = mock_send_domain_email.call_args.kwargs
self.assertEqual(call_args["email"], "mayor@igorville.gov") self.assertEqual(call_args["email"], "mayor@igorville.gov")
self.assertEqual(call_args["requestor"], self.user) self.assertEqual(call_args["requestor"], self.user)
self.assertEqual(call_args["domain"], self.domain) self.assertEqual(call_args["domains"], self.domain)
self.assertIsNone(call_args.get("is_member_of_different_org")) self.assertIsNone(call_args.get("is_member_of_different_org"))
# Assert that the PortfolioInvitation is created # Assert that the PortfolioInvitation is created and retrieved
portfolio_invitation = PortfolioInvitation.objects.filter( portfolio_invitation = PortfolioInvitation.objects.filter(
email="mayor@igorville.gov", portfolio=self.portfolio email="mayor@igorville.gov", portfolio=self.portfolio
).first() ).first()
self.assertIsNotNone(portfolio_invitation, "Portfolio invitation should be created.") self.assertIsNotNone(portfolio_invitation, "Portfolio invitation should be created.")
self.assertEqual(portfolio_invitation.email, "mayor@igorville.gov") self.assertEqual(portfolio_invitation.email, "mayor@igorville.gov")
self.assertEqual(portfolio_invitation.portfolio, self.portfolio) self.assertEqual(portfolio_invitation.portfolio, self.portfolio)
self.assertEqual(portfolio_invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
# Assert that the UserPortfolioPermission is created
user_portfolio_permission = UserPortfolioPermission.objects.filter(
user=self.user, portfolio=self.portfolio
).first()
self.assertIsNotNone(user_portfolio_permission, "User portfolio permission should be created")
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow() success_page = success_result.follow()
self.assertContains(success_page, "mayor@igorville.gov") self.assertContains(success_page, "mayor@igorville.gov")
@boto3_mocking.patching
@override_flag("organization_feature", active=True)
@less_console_noise_decorator
@patch("registrar.views.domain.send_portfolio_invitation_email")
@patch("registrar.views.domain.send_domain_invitation_email")
def test_domain_user_add_form_sends_portfolio_invitation_to_new_email(
self, mock_send_domain_email, mock_send_portfolio_email
):
"""Adding an email not associated with a user works and sends portfolio invitation."""
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "notauser@igorville.gov"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_result = add_page.form.submit()
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
)
# Verify that the invitation emails were sent
mock_send_portfolio_email.assert_called_once_with(
email="notauser@igorville.gov", requestor=self.user, portfolio=self.portfolio
)
mock_send_domain_email.assert_called_once()
call_args = mock_send_domain_email.call_args.kwargs
self.assertEqual(call_args["email"], "notauser@igorville.gov")
self.assertEqual(call_args["requestor"], self.user)
self.assertEqual(call_args["domains"], self.domain)
self.assertIsNone(call_args.get("is_member_of_different_org"))
# Assert that the PortfolioInvitation is created
portfolio_invitation = PortfolioInvitation.objects.filter(
email="notauser@igorville.gov", portfolio=self.portfolio
).first()
self.assertIsNotNone(portfolio_invitation, "Portfolio invitation should be created.")
self.assertEqual(portfolio_invitation.email, "notauser@igorville.gov")
self.assertEqual(portfolio_invitation.portfolio, self.portfolio)
self.assertEqual(portfolio_invitation.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
self.assertContains(success_page, "notauser@igorville.gov")
@boto3_mocking.patching @boto3_mocking.patching
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@less_console_noise_decorator @less_console_noise_decorator
@ -701,7 +914,7 @@ class TestDomainManagers(TestDomainOverview):
call_args = mock_send_domain_email.call_args.kwargs call_args = mock_send_domain_email.call_args.kwargs
self.assertEqual(call_args["email"], "mayor@igorville.gov") self.assertEqual(call_args["email"], "mayor@igorville.gov")
self.assertEqual(call_args["requestor"], self.user) self.assertEqual(call_args["requestor"], self.user)
self.assertEqual(call_args["domain"], self.domain) self.assertEqual(call_args["domains"], self.domain)
self.assertIsNone(call_args.get("is_member_of_different_org")) self.assertIsNone(call_args.get("is_member_of_different_org"))
# Assert that no PortfolioInvitation is created # Assert that no PortfolioInvitation is created
@ -759,7 +972,7 @@ class TestDomainManagers(TestDomainOverview):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow() success_page = success_result.follow()
self.assertContains(success_page, "Could not send email invitation.") self.assertContains(success_page, "Failed to send email.")
@boto3_mocking.patching @boto3_mocking.patching
@less_console_noise_decorator @less_console_noise_decorator
@ -2660,7 +2873,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 +2910,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

@ -2106,25 +2106,75 @@ class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView): class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
cls.url = reverse("member-domains-edit", kwargs={"pk": cls.portfolio_permission.pk}) # Create Portfolio
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
# Create domains for testing
cls.domain1 = Domain.objects.create(name="1.gov")
cls.domain2 = Domain.objects.create(name="2.gov")
cls.domain3 = Domain.objects.create(name="3.gov")
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
super().tearDownClass() super().tearDownClass()
Portfolio.objects.all().delete()
User.objects.all().delete()
Domain.objects.all().delete()
def setUp(self): def setUp(self):
super().setUp() super().setUp()
names = ["1.gov", "2.gov", "3.gov"] # Create test member
Domain.objects.bulk_create([Domain(name=name) for name in names]) self.user_member = User.objects.create(
username="test_member",
first_name="Second",
last_name="User",
email="second@example.com",
phone="8003112345",
title="Member",
)
# Create test user with no perms
self.user_no_perms = User.objects.create(
username="test_user_no_perms",
first_name="No",
last_name="Permissions",
email="user_no_perms@example.com",
phone="8003112345",
title="No Permissions",
)
# Assign permissions to the user making requests
self.portfolio_permission = UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Assign permissions to test member
self.permission = UserPortfolioPermission.objects.create(
user=self.user_member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Create url to be used in all tests
self.url = reverse("member-domains-edit", kwargs={"pk": self.portfolio_permission.pk})
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
Domain.objects.all().delete() DomainInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
PortfolioInvitation.objects.all().delete()
Portfolio.objects.exclude(id=self.portfolio.id).delete()
User.objects.exclude(id=self.user.id).delete()
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@ -2180,12 +2230,13 @@ class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView):
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True) @override_flag("organization_members", active=True)
def test_post_with_valid_added_domains(self): @patch("registrar.views.portfolios.send_domain_invitation_email")
def test_post_with_valid_added_domains(self, mock_send_domain_email):
"""Test that domains can be successfully added.""" """Test that domains can be successfully added."""
self.client.force_login(self.user) self.client.force_login(self.user)
data = { data = {
"added_domains": json.dumps([1, 2, 3]), # Mock domain IDs "added_domains": json.dumps([self.domain1.id, self.domain2.id, self.domain3.id]), # Mock domain IDs
} }
response = self.client.post(self.url, data) response = self.client.post(self.url, data)
@ -2198,31 +2249,43 @@ class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView):
self.assertEqual(len(messages), 1) self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
expected_domains = [self.domain1, self.domain2, self.domain3]
# Verify that the invitation email was sent
mock_send_domain_email.assert_called_once()
call_args = mock_send_domain_email.call_args.kwargs
self.assertEqual(call_args["email"], "info@example.com")
self.assertEqual(call_args["requestor"], self.user)
self.assertEqual(list(call_args["domains"]), list(expected_domains))
self.assertIsNone(call_args.get("is_member_of_different_org"))
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True) @override_flag("organization_members", active=True)
def test_post_with_valid_removed_domains(self): @patch("registrar.views.portfolios.send_domain_invitation_email")
def test_post_with_valid_removed_domains(self, mock_send_domain_email):
"""Test that domains can be successfully removed.""" """Test that domains can be successfully removed."""
self.client.force_login(self.user) self.client.force_login(self.user)
# Create some UserDomainRole objects # Create some UserDomainRole objects
domains = [1, 2, 3] domains = [self.domain1, self.domain2, self.domain3]
UserDomainRole.objects.bulk_create([UserDomainRole(domain_id=domain, user=self.user) for domain in domains]) UserDomainRole.objects.bulk_create([UserDomainRole(domain=domain, user=self.user) for domain in domains])
data = { data = {
"removed_domains": json.dumps([1, 2]), "removed_domains": json.dumps([self.domain1.id, self.domain2.id]),
} }
response = self.client.post(self.url, data) response = self.client.post(self.url, data)
# Check that the UserDomainRole objects were deleted # Check that the UserDomainRole objects were deleted
self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 1) self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 1)
self.assertEqual(UserDomainRole.objects.filter(domain_id=3, user=self.user).count(), 1) self.assertEqual(UserDomainRole.objects.filter(domain=self.domain3, user=self.user).count(), 1)
# Check for a success message and a redirect # Check for a success message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages) messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1) self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
# assert that send_domain_invitation_email is not called
mock_send_domain_email.assert_not_called()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
@ -2290,26 +2353,93 @@ class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView):
self.assertEqual(len(messages), 1) self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "No changes detected.") self.assertEqual(str(messages[0]), "No changes detected.")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_domain_invitation_email")
def test_post_when_send_domain_email_raises_exception(self, mock_send_domain_email):
"""Test attempt to add new domains when an EmailSendingError raised."""
self.client.force_login(self.user)
class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomainsView): data = {
"added_domains": json.dumps([self.domain1.id, self.domain2.id, self.domain3.id]), # Mock domain IDs
}
mock_send_domain_email.side_effect = EmailSendingError("Failed to send email")
response = self.client.post(self.url, data)
# Check that the UserDomainRole objects were not created
self.assertEqual(UserDomainRole.objects.filter(user=self.user, role=UserDomainRole.Roles.MANAGER).count(), 0)
# Check for an error message and a redirect to edit form
self.assertRedirects(response, reverse("member-domains-edit", kwargs={"pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
str(messages[0]),
"An unexpected error occurred: Failed to send email. If the issue persists, please contact help@get.gov.",
)
class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
cls.url = reverse("invitedmember-domains-edit", kwargs={"pk": cls.invitation.pk}) # Create Portfolio
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
# Create domains for testing
cls.domain1 = Domain.objects.create(name="1.gov")
cls.domain2 = Domain.objects.create(name="2.gov")
cls.domain3 = Domain.objects.create(name="3.gov")
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
super().tearDownClass() super().tearDownClass()
Portfolio.objects.all().delete()
User.objects.all().delete()
Domain.objects.all().delete()
def setUp(self): def setUp(self):
super().setUp() super().setUp()
names = ["1.gov", "2.gov", "3.gov"] # Add a user with no permissions
Domain.objects.bulk_create([Domain(name=name) for name in names]) self.user_no_perms = User.objects.create(
username="test_user_no_perms",
first_name="No",
last_name="Permissions",
email="user_no_perms@example.com",
phone="8003112345",
title="No Permissions",
)
# Add an invited member who has been invited to manage domains
self.invited_member_email = "invited@example.com"
self.invitation = PortfolioInvitation.objects.create(
email=self.invited_member_email,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
# Assign permissions to the user making requests
UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
self.url = reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.pk})
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
Domain.objects.all().delete() Domain.objects.all().delete()
DomainInvitation.objects.all().delete() DomainInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
PortfolioInvitation.objects.all().delete()
Portfolio.objects.exclude(id=self.portfolio.id).delete()
User.objects.exclude(id=self.user.id).delete()
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@ -2364,12 +2494,13 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True) @override_flag("organization_members", active=True)
def test_post_with_valid_added_domains(self): @patch("registrar.views.portfolios.send_domain_invitation_email")
def test_post_with_valid_added_domains(self, mock_send_domain_email):
"""Test adding new domains successfully.""" """Test adding new domains successfully."""
self.client.force_login(self.user) self.client.force_login(self.user)
data = { data = {
"added_domains": json.dumps([1, 2, 3]), # Mock domain IDs "added_domains": json.dumps([self.domain1.id, self.domain2.id, self.domain3.id]),
} }
response = self.client.post(self.url, data) response = self.client.post(self.url, data)
@ -2387,10 +2518,20 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain
self.assertEqual(len(messages), 1) self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
expected_domains = [self.domain1, self.domain2, self.domain3]
# Verify that the invitation email was sent
mock_send_domain_email.assert_called_once()
call_args = mock_send_domain_email.call_args.kwargs
self.assertEqual(call_args["email"], "invited@example.com")
self.assertEqual(call_args["requestor"], self.user)
self.assertEqual(list(call_args["domains"]), list(expected_domains))
self.assertFalse(call_args.get("is_member_of_different_org"))
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True) @override_flag("organization_members", active=True)
def test_post_with_existing_and_new_added_domains(self): @patch("registrar.views.portfolios.send_domain_invitation_email")
def test_post_with_existing_and_new_added_domains(self, _):
"""Test updating existing and adding new invitations.""" """Test updating existing and adding new invitations."""
self.client.force_login(self.user) self.client.force_login(self.user)
@ -2398,29 +2539,33 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain
DomainInvitation.objects.bulk_create( DomainInvitation.objects.bulk_create(
[ [
DomainInvitation( DomainInvitation(
domain_id=1, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.CANCELED domain=self.domain1,
email="invited@example.com",
status=DomainInvitation.DomainInvitationStatus.CANCELED,
), ),
DomainInvitation( DomainInvitation(
domain_id=2, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED domain=self.domain2,
email="invited@example.com",
status=DomainInvitation.DomainInvitationStatus.INVITED,
), ),
] ]
) )
data = { data = {
"added_domains": json.dumps([1, 2, 3]), "added_domains": json.dumps([self.domain1.id, self.domain2.id, self.domain3.id]),
} }
response = self.client.post(self.url, data) response = self.client.post(self.url, data)
# Check that status for domain_id=1 was updated to INVITED # Check that status for domain_id=1 was updated to INVITED
self.assertEqual( self.assertEqual(
DomainInvitation.objects.get(domain_id=1, email="invited@example.com").status, DomainInvitation.objects.get(domain=self.domain1, email="invited@example.com").status,
DomainInvitation.DomainInvitationStatus.INVITED, DomainInvitation.DomainInvitationStatus.INVITED,
) )
# Check that domain_id=3 was created as INVITED # Check that domain_id=3 was created as INVITED
self.assertTrue( self.assertTrue(
DomainInvitation.objects.filter( DomainInvitation.objects.filter(
domain_id=3, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED domain=self.domain3, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
).exists() ).exists()
) )
@ -2430,7 +2575,8 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True) @override_flag("organization_members", active=True)
def test_post_with_valid_removed_domains(self): @patch("registrar.views.portfolios.send_domain_invitation_email")
def test_post_with_valid_removed_domains(self, mock_send_domain_email):
"""Test removing domains successfully.""" """Test removing domains successfully."""
self.client.force_login(self.user) self.client.force_login(self.user)
@ -2438,33 +2584,39 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain
DomainInvitation.objects.bulk_create( DomainInvitation.objects.bulk_create(
[ [
DomainInvitation( DomainInvitation(
domain_id=1, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED domain=self.domain1,
email="invited@example.com",
status=DomainInvitation.DomainInvitationStatus.INVITED,
), ),
DomainInvitation( DomainInvitation(
domain_id=2, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED domain=self.domain2,
email="invited@example.com",
status=DomainInvitation.DomainInvitationStatus.INVITED,
), ),
] ]
) )
data = { data = {
"removed_domains": json.dumps([1]), "removed_domains": json.dumps([self.domain1.id]),
} }
response = self.client.post(self.url, data) response = self.client.post(self.url, data)
# Check that the status for domain_id=1 was updated to CANCELED # Check that the status for domain_id=1 was updated to CANCELED
self.assertEqual( self.assertEqual(
DomainInvitation.objects.get(domain_id=1, email="invited@example.com").status, DomainInvitation.objects.get(domain=self.domain1, email="invited@example.com").status,
DomainInvitation.DomainInvitationStatus.CANCELED, DomainInvitation.DomainInvitationStatus.CANCELED,
) )
# Check that domain_id=2 remains INVITED # Check that domain_id=2 remains INVITED
self.assertEqual( self.assertEqual(
DomainInvitation.objects.get(domain_id=2, email="invited@example.com").status, DomainInvitation.objects.get(domain=self.domain2, email="invited@example.com").status,
DomainInvitation.DomainInvitationStatus.INVITED, DomainInvitation.DomainInvitationStatus.INVITED,
) )
# Check for a success message and a redirect # Check for a success message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
# assert that send_domain_invitation_email is not called
mock_send_domain_email.assert_not_called()
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@ -2530,6 +2682,37 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain
self.assertEqual(len(messages), 1) self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "No changes detected.") self.assertEqual(str(messages[0]), "No changes detected.")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_domain_invitation_email")
def test_post_when_send_domain_email_raises_exception(self, mock_send_domain_email):
"""Test attempt to add new domains when an EmailSendingError raised."""
self.client.force_login(self.user)
data = {
"added_domains": json.dumps([self.domain1.id, self.domain2.id, self.domain3.id]),
}
mock_send_domain_email.side_effect = EmailSendingError("Failed to send email")
response = self.client.post(self.url, data)
# Check that the DomainInvitation objects were not created
self.assertEqual(
DomainInvitation.objects.filter(
email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
).count(),
0,
)
# Check for an error message and a redirect to edit form
self.assertRedirects(response, reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
str(messages[0]),
"An unexpected error occurred: Failed to send email. If the issue persists, please contact help@get.gov.",
)
class TestRequestingEntity(WebTest): class TestRequestingEntity(WebTest):
"""The requesting entity page is a domain request form that only exists """The requesting entity page is a domain request form that only exists
@ -2879,7 +3062,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
], ],
) )
cls.new_member_email = "davekenn4242@gmail.com" cls.new_member_email = "newmember@example.com"
AllowedEmail.objects.get_or_create(email=cls.new_member_email) AllowedEmail.objects.get_or_create(email=cls.new_member_email)
@ -2933,11 +3116,13 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
self.assertEqual(final_response.status_code, 302) # Redirects self.assertEqual(final_response.status_code, 302) # Redirects
# Validate Database Changes # Validate Database Changes
# Validate that portfolio invitation was created but not retrieved
portfolio_invite = PortfolioInvitation.objects.filter( portfolio_invite = PortfolioInvitation.objects.filter(
email=self.new_member_email, portfolio=self.portfolio email=self.new_member_email, portfolio=self.portfolio
).first() ).first()
self.assertIsNotNone(portfolio_invite) self.assertIsNotNone(portfolio_invite)
self.assertEqual(portfolio_invite.email, self.new_member_email) self.assertEqual(portfolio_invite.email, self.new_member_email)
self.assertEqual(portfolio_invite.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
# Check that an email was sent # Check that an email was sent
self.assertTrue(mock_client.send_email.called) self.assertTrue(mock_client.send_email.called)
@ -3228,6 +3413,52 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# assert that send_portfolio_invitation_email is not called # assert that send_portfolio_invitation_email is not called
mock_send_email.assert_not_called() mock_send_email.assert_not_called()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
def test_member_invite_for_existing_user_who_is_not_a_member(self, mock_send_email):
"""Tests the member invitation flow for existing user who is not a portfolio member."""
self.client.force_login(self.user)
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
new_user = User.objects.create(email="newuser@example.com")
# Simulate submission of member invite for the newly created user
response = self.client.post(
reverse("new-member"),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": "newuser@example.com",
},
)
self.assertEqual(response.status_code, 302)
# Validate Database Changes
# Validate that portfolio invitation was created and retrieved
portfolio_invite = PortfolioInvitation.objects.filter(
email="newuser@example.com", portfolio=self.portfolio
).first()
self.assertIsNotNone(portfolio_invite)
self.assertEqual(portfolio_invite.email, "newuser@example.com")
self.assertEqual(portfolio_invite.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
# Validate UserPortfolioPermission
user_portfolio_permission = UserPortfolioPermission.objects.filter(
user=new_user, portfolio=self.portfolio
).first()
self.assertIsNotNone(user_portfolio_permission)
# assert that send_portfolio_invitation_email is called
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args.kwargs
self.assertEqual(call_args["email"], "newuser@example.com")
self.assertEqual(call_args["requestor"], self.user)
self.assertIsNone(call_args.get("is_member_of_different_org"))
class TestEditPortfolioMemberView(WebTest): class TestEditPortfolioMemberView(WebTest):
"""Tests for the edit member page on portfolios""" """Tests for the edit member page on portfolios"""

View file

@ -1,5 +1,6 @@
from django.conf import settings from django.conf import settings
from registrar.models import DomainInvitation from registrar.models import DomainInvitation
from registrar.models.domain import Domain
from registrar.utility.errors import ( from registrar.utility.errors import (
AlreadyDomainInvitedError, AlreadyDomainInvitedError,
AlreadyDomainManagerError, AlreadyDomainManagerError,
@ -7,23 +8,24 @@ from registrar.utility.errors import (
OutsideOrgMemberError, OutsideOrgMemberError,
) )
from registrar.utility.waffle import flag_is_active_for_user from registrar.utility.waffle import flag_is_active_for_user
from registrar.utility.email import send_templated_email from registrar.utility.email import EmailSendingError, send_templated_email
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def send_domain_invitation_email(email: str, requestor, domain, is_member_of_different_org): def send_domain_invitation_email(
email: str, requestor, domains: Domain | list[Domain], is_member_of_different_org, requested_user=None
):
""" """
Sends a domain invitation email to the specified address. Sends a domain invitation email to the specified address.
Raises exceptions for validation or email-sending issues.
Args: Args:
email (str): Email address of the recipient. email (str): Email address of the recipient.
requestor (User): The user initiating the invitation. requestor (User): The user initiating the invitation.
domain (Domain): The domain object for which the invitation is being sent. domains (Domain or list of Domain): The domain objects for which the invitation is being sent.
is_member_of_different_org (bool): if an email belongs to a different org is_member_of_different_org (bool): if an email belongs to a different org
requested_user (User | None): The recipient if the email belongs to a user in the registrar
Raises: Raises:
MissingEmailError: If the requestor has no email associated with their account. MissingEmailError: If the requestor has no email associated with their account.
@ -32,26 +34,54 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif
OutsideOrgMemberError: If the requested_user is part of a different organization. OutsideOrgMemberError: If the requested_user is part of a different organization.
EmailSendingError: If there is an error while sending the email. EmailSendingError: If there is an error while sending the email.
""" """
# Default email address for staff domains = normalize_domains(domains)
requestor_email = settings.DEFAULT_FROM_EMAIL requestor_email = get_requestor_email(requestor, domains)
validate_invitation(email, domains, requestor, is_member_of_different_org)
send_invitation_email(email, requestor_email, domains, requested_user)
def normalize_domains(domains):
"""Ensures domains is always a list."""
return [domains] if isinstance(domains, Domain) else domains
def get_requestor_email(requestor, domains):
"""Get the requestor's email or raise an error if it's missing.
If the requestor is staff, default email is returned.
"""
if requestor.is_staff:
return settings.DEFAULT_FROM_EMAIL
# Check if the requestor is staff and has an email
if not requestor.is_staff:
if not requestor.email or requestor.email.strip() == "": if not requestor.email or requestor.email.strip() == "":
raise MissingEmailError domain_names = ", ".join([domain.name for domain in domains])
else: raise MissingEmailError(email=requestor.email, domain=domain_names)
requestor_email = requestor.email
# Check if the recipient is part of a different organization return requestor.email
# COMMENT: this does not account for multiple_portfolios flag being active
def validate_invitation(email, domains, requestor, is_member_of_different_org):
"""Validate the invitation conditions."""
check_outside_org_membership(email, requestor, is_member_of_different_org)
for domain in domains:
validate_existing_invitation(email, domain)
def check_outside_org_membership(email, requestor, is_member_of_different_org):
"""Raise an error if the email belongs to a different organization."""
if ( if (
flag_is_active_for_user(requestor, "organization_feature") flag_is_active_for_user(requestor, "organization_feature")
and not flag_is_active_for_user(requestor, "multiple_portfolios") and not flag_is_active_for_user(requestor, "multiple_portfolios")
and is_member_of_different_org and is_member_of_different_org
): ):
raise OutsideOrgMemberError raise OutsideOrgMemberError(email=email)
# Check for an existing invitation
def validate_existing_invitation(email, domain):
"""Check for existing invitations and handle their status."""
try: try:
invite = DomainInvitation.objects.get(email=email, domain=domain) invite = DomainInvitation.objects.get(email=email, domain=domain)
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
@ -64,16 +94,24 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif
except DomainInvitation.DoesNotExist: except DomainInvitation.DoesNotExist:
pass pass
# Send the email
def send_invitation_email(email, requestor_email, domains, requested_user):
"""Send the invitation email."""
try:
send_templated_email( send_templated_email(
"emails/domain_invitation.txt", "emails/domain_invitation.txt",
"emails/domain_invitation_subject.txt", "emails/domain_invitation_subject.txt",
to_address=email, to_address=email,
context={ context={
"domain": domain, "domains": domains,
"requestor_email": requestor_email, "requestor_email": requestor_email,
"invitee_email_address": email,
"requested_user": requested_user,
}, },
) )
except EmailSendingError as err:
domain_names = ", ".join([domain.name for domain in domains])
raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err
def send_portfolio_invitation_email(email: str, requestor, portfolio): def send_portfolio_invitation_email(email: str, requestor, portfolio):
@ -98,10 +136,11 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio):
# Check if the requestor is staff and has an email # Check if the requestor is staff and has an email
if not requestor.is_staff: if not requestor.is_staff:
if not requestor.email or requestor.email.strip() == "": if not requestor.email or requestor.email.strip() == "":
raise MissingEmailError raise MissingEmailError(email=email, portfolio=portfolio)
else: else:
requestor_email = requestor.email requestor_email = requestor.email
try:
send_templated_email( send_templated_email(
"emails/portfolio_invitation.txt", "emails/portfolio_invitation.txt",
"emails/portfolio_invitation_subject.txt", "emails/portfolio_invitation_subject.txt",
@ -112,3 +151,7 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio):
"email": email, "email": email,
}, },
) )
except EmailSendingError as err:
raise EmailSendingError(
f"Could not sent email invitation to {email} for portfolio {portfolio}. Portfolio invitation not saved."
) from err

View file

@ -46,8 +46,17 @@ class AlreadyDomainInvitedError(InvitationError):
class MissingEmailError(InvitationError): class MissingEmailError(InvitationError):
"""Raised when the requestor has no email associated with their account.""" """Raised when the requestor has no email associated with their account."""
def __init__(self): def __init__(self, email=None, domain=None, portfolio=None):
super().__init__("Can't send invitation email. No email is associated with your user account.") # Default message if no additional info is provided
message = "Can't send invitation email. No email is associated with your user account."
# Customize message based on provided arguments
if email and domain:
message = f"Can't send email to '{email}' on domain '{domain}'. No email exists for the requestor."
elif email and portfolio:
message = f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor."
super().__init__(message)
class OutsideOrgMemberError(ValueError): class OutsideOrgMemberError(ValueError):

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

@ -10,13 +10,12 @@ import logging
import requests import requests
from django.contrib import messages 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.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,
@ -31,22 +30,23 @@ from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.enums import DefaultEmail from registrar.utility.enums import DefaultEmail
from registrar.utility.errors import ( from registrar.utility.errors import (
AlreadyDomainInvitedError,
AlreadyDomainManagerError,
GenericError, GenericError,
GenericErrorCodes, GenericErrorCodes,
MissingEmailError,
NameserverError, NameserverError,
NameserverErrorCodes as nsErrorCodes, NameserverErrorCodes as nsErrorCodes,
DsDataError, DsDataError,
DsDataErrorCodes, DsDataErrorCodes,
SecurityEmailError, SecurityEmailError,
SecurityEmailErrorCodes, SecurityEmailErrorCodes,
OutsideOrgMemberError,
) )
from registrar.models.utility.contact_error import ContactError from registrar.models.utility.contact_error import ContactError
from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView
from registrar.utility.waffle import flag_is_active_for_user from registrar.utility.waffle import flag_is_active_for_user
from registrar.views.utility.invitation_helper import (
get_org_membership,
get_requested_user,
handle_invitation_exceptions,
)
from ..forms import ( from ..forms import (
SeniorOfficialContactForm, SeniorOfficialContactForm,
@ -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"""
@ -1149,43 +1190,13 @@ class DomainAddUserView(DomainFormBaseView):
def get_success_url(self): def get_success_url(self):
return reverse("domain-users", kwargs={"pk": self.object.pk}) return reverse("domain-users", kwargs={"pk": self.object.pk})
def _get_org_membership(self, requestor_org, requested_email, requested_user):
"""
Verifies if an email belongs to a different organization as a member or invited member.
Verifies if an email belongs to this organization as a member or invited member.
User does not belong to any org can be deduced from the tuple returned.
Returns a tuple (member_of_a_different_org, member_of_this_org).
"""
# COMMENT: this code does not take into account multiple portfolios flag
# COMMENT: shouldn't this code be based on the organization of the domain, not the org
# of the requestor? requestor could have multiple portfolios
# Check for existing permissions or invitations for the requested user
existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first()
existing_org_invitation = PortfolioInvitation.objects.filter(email=requested_email).first()
# Determine membership in a different organization
member_of_a_different_org = (
existing_org_permission and existing_org_permission.portfolio != requestor_org
) or (existing_org_invitation and existing_org_invitation.portfolio != requestor_org)
# Determine membership in the same organization
member_of_this_org = (existing_org_permission and existing_org_permission.portfolio == requestor_org) or (
existing_org_invitation and existing_org_invitation.portfolio == requestor_org
)
return member_of_a_different_org, member_of_this_org
def form_valid(self, form): def form_valid(self, form):
"""Add the specified user to this domain.""" """Add the specified user to this domain."""
requested_email = form.cleaned_data["email"] requested_email = form.cleaned_data["email"]
requestor = self.request.user requestor = self.request.user
# Look up a user with that email # Look up a user with that email
requested_user = self._get_requested_user(requested_email) requested_user = get_requested_user(requested_email)
# NOTE: This does not account for multiple portfolios flag being set to True # NOTE: This does not account for multiple portfolios flag being set to True
domain_org = self.object.domain_info.portfolio domain_org = self.object.domain_info.portfolio
@ -1196,13 +1207,12 @@ class DomainAddUserView(DomainFormBaseView):
or requestor.is_staff or requestor.is_staff
) )
member_of_a_different_org, member_of_this_org = self._get_org_membership( member_of_a_different_org, member_of_this_org = get_org_membership(domain_org, requested_email, requested_user)
domain_org, requested_email, requested_user try:
) # COMMENT: this code does not take into account multiple portfolios flag being set to TRUE
# determine portfolio of the domain (code currently is looking at requestor's portfolio) # determine portfolio of the domain (code currently is looking at requestor's portfolio)
# if requested_email/user is not member or invited member of this portfolio # if requested_email/user is not member or invited member of this portfolio
# COMMENT: this code does not take into account multiple portfolios flag
# send portfolio invitation email # send portfolio invitation email
# create portfolio invitation # create portfolio invitation
# create message to view # create message to view
@ -1213,38 +1223,31 @@ class DomainAddUserView(DomainFormBaseView):
and requestor_can_update_portfolio and requestor_can_update_portfolio
and not member_of_this_org and not member_of_this_org
): ):
try:
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org) send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org)
PortfolioInvitation.objects.get_or_create(email=requested_email, portfolio=domain_org) portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
email=requested_email, portfolio=domain_org, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
# if user exists for email, immediately retrieve portfolio invitation upon creation
if requested_user is not None:
portfolio_invitation.retrieve()
portfolio_invitation.save()
messages.success(self.request, f"{requested_email} has been invited to the organization: {domain_org}") messages.success(self.request, f"{requested_email} has been invited to the organization: {domain_org}")
except Exception as e:
self._handle_portfolio_exceptions(e, requested_email, domain_org)
# If that first invite does not succeed take an early exit
return redirect(self.get_success_url())
try:
if requested_user is None: if requested_user is None:
self._handle_new_user_invitation(requested_email, requestor, member_of_a_different_org) self._handle_new_user_invitation(requested_email, requestor, member_of_a_different_org)
else: else:
self._handle_existing_user(requested_email, requestor, requested_user, member_of_a_different_org) self._handle_existing_user(requested_email, requestor, requested_user, member_of_a_different_org)
except Exception as e: except Exception as e:
self._handle_exceptions(e, requested_email) handle_invitation_exceptions(self.request, e, requested_email)
return redirect(self.get_success_url()) return redirect(self.get_success_url())
def _get_requested_user(self, email):
"""Retrieve a user by email or return None if the user doesn't exist."""
try:
return User.objects.get(email=email)
except User.DoesNotExist:
return None
def _handle_new_user_invitation(self, email, requestor, member_of_different_org): def _handle_new_user_invitation(self, email, requestor, member_of_different_org):
"""Handle invitation for a new user who does not exist in the system.""" """Handle invitation for a new user who does not exist in the system."""
send_domain_invitation_email( send_domain_invitation_email(
email=email, email=email,
requestor=requestor, requestor=requestor,
domain=self.object, domains=self.object,
is_member_of_different_org=member_of_different_org, is_member_of_different_org=member_of_different_org,
) )
DomainInvitation.objects.get_or_create(email=email, domain=self.object) DomainInvitation.objects.get_or_create(email=email, domain=self.object)
@ -1255,8 +1258,9 @@ class DomainAddUserView(DomainFormBaseView):
send_domain_invitation_email( send_domain_invitation_email(
email=email, email=email,
requestor=requestor, requestor=requestor,
domain=self.object, domains=self.object,
is_member_of_different_org=member_of_different_org, is_member_of_different_org=member_of_different_org,
requested_user=requested_user,
) )
UserDomainRole.objects.create( UserDomainRole.objects.create(
user=requested_user, user=requested_user,
@ -1265,57 +1269,6 @@ class DomainAddUserView(DomainFormBaseView):
) )
messages.success(self.request, f"Added user {email}.") messages.success(self.request, f"Added user {email}.")
def _handle_exceptions(self, exception, email):
"""Handle exceptions raised during the process."""
if isinstance(exception, EmailSendingError):
logger.warning(
"Could not send email invitation to %s for domain %s (EmailSendingError)",
email,
self.object,
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
elif isinstance(exception, OutsideOrgMemberError):
logger.warning(
"Could not send email. Can not invite member of a .gov organization to a different organization.",
self.object,
exc_info=True,
)
messages.error(
self.request,
f"{email} is already a member of another .gov organization.",
)
elif isinstance(exception, AlreadyDomainManagerError):
messages.warning(self.request, str(exception))
elif isinstance(exception, AlreadyDomainInvitedError):
messages.warning(self.request, str(exception))
elif isinstance(exception, MissingEmailError):
messages.error(self.request, str(exception))
logger.error(
f"Can't send email to '{email}' on domain '{self.object}'. No email exists for the requestor.",
exc_info=True,
)
elif isinstance(exception, IntegrityError):
messages.warning(self.request, f"{email} is already a manager for this domain")
else:
logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.warning(self.request, "Could not send email invitation.")
def _handle_portfolio_exceptions(self, exception, email, portfolio):
"""Handle exceptions raised during the process."""
if isinstance(exception, EmailSendingError):
logger.warning("Could not send email invitation (EmailSendingError)", exc_info=True)
messages.warning(self.request, "Could not send email invitation.")
elif isinstance(exception, MissingEmailError):
messages.error(self.request, str(exception))
logger.error(
f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor.",
exc_info=True,
)
else:
logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.warning(self.request, "Could not send email invitation.")
class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView): class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView):
object: DomainInvitation object: DomainInvitation

View file

@ -8,13 +8,14 @@ from django.utils.safestring import mark_safe
from django.contrib import messages from django.contrib import messages
from registrar.forms import portfolio as portfolioForms from registrar.forms import portfolio as portfolioForms
from registrar.models import Portfolio, User from registrar.models import Portfolio, User
from registrar.models.domain import Domain
from registrar.models.domain_invitation import DomainInvitation from registrar.models.domain_invitation import DomainInvitation
from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.utility.email import EmailSendingError from registrar.utility.email import EmailSendingError
from registrar.utility.email_invitations import send_portfolio_invitation_email from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
from registrar.utility.errors import MissingEmailError from registrar.utility.errors import MissingEmailError
from registrar.utility.enums import DefaultUserValues from registrar.utility.enums import DefaultUserValues
from registrar.views.utility.mixins import PortfolioMemberPermission from registrar.views.utility.mixins import PortfolioMemberPermission
@ -33,6 +34,8 @@ from django.views.generic import View
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django.db import IntegrityError from django.db import IntegrityError
from registrar.views.utility.invitation_helper import get_org_membership
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -237,6 +240,7 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V
removed_domains = request.POST.get("removed_domains") removed_domains = request.POST.get("removed_domains")
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
member = portfolio_permission.user member = portfolio_permission.user
portfolio = portfolio_permission.portfolio
added_domain_ids = self._parse_domain_ids(added_domains, "added domains") added_domain_ids = self._parse_domain_ids(added_domains, "added domains")
if added_domain_ids is None: if added_domain_ids is None:
@ -248,7 +252,7 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V
if added_domain_ids or removed_domain_ids: if added_domain_ids or removed_domain_ids:
try: try:
self._process_added_domains(added_domain_ids, member) self._process_added_domains(added_domain_ids, member, request.user, portfolio)
self._process_removed_domains(removed_domain_ids, member) self._process_removed_domains(removed_domain_ids, member)
messages.success(request, "The domain assignment changes have been saved.") messages.success(request, "The domain assignment changes have been saved.")
return redirect(reverse("member-domains", kwargs={"pk": pk})) return redirect(reverse("member-domains", kwargs={"pk": pk}))
@ -258,15 +262,15 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V
"A database error occurred while saving changes. If the issue persists, " "A database error occurred while saving changes. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.", f"please contact {DefaultUserValues.HELP_EMAIL}.",
) )
logger.error("A database error occurred while saving changes.") logger.error("A database error occurred while saving changes.", exc_info=True)
return redirect(reverse("member-domains-edit", kwargs={"pk": pk})) return redirect(reverse("member-domains-edit", kwargs={"pk": pk}))
except Exception as e: except Exception as e:
messages.error( messages.error(
request, request,
"An unexpected error occurred: {str(e)}. If the issue persists, " f"An unexpected error occurred: {str(e)}. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.", f"please contact {DefaultUserValues.HELP_EMAIL}.",
) )
logger.error(f"An unexpected error occurred: {str(e)}") logger.error(f"An unexpected error occurred: {str(e)}", exc_info=True)
return redirect(reverse("member-domains-edit", kwargs={"pk": pk})) return redirect(reverse("member-domains-edit", kwargs={"pk": pk}))
else: else:
messages.info(request, "No changes detected.") messages.info(request, "No changes detected.")
@ -287,16 +291,26 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V
logger.error(f"Invalid data for {domain_type}") logger.error(f"Invalid data for {domain_type}")
return None return None
def _process_added_domains(self, added_domain_ids, member): def _process_added_domains(self, added_domain_ids, member, requestor, portfolio):
""" """
Processes added domains by bulk creating UserDomainRole instances. Processes added domains by bulk creating UserDomainRole instances.
""" """
if added_domain_ids: if added_domain_ids:
# get added_domains from ids to pass to send email method and bulk create
added_domains = Domain.objects.filter(id__in=added_domain_ids)
member_of_a_different_org, _ = get_org_membership(portfolio, member.email, member)
send_domain_invitation_email(
email=member.email,
requestor=requestor,
domains=added_domains,
is_member_of_different_org=member_of_a_different_org,
requested_user=member,
)
# Bulk create UserDomainRole instances for added domains # Bulk create UserDomainRole instances for added domains
UserDomainRole.objects.bulk_create( UserDomainRole.objects.bulk_create(
[ [
UserDomainRole(domain_id=domain_id, user=member, role=UserDomainRole.Roles.MANAGER) UserDomainRole(domain=domain, user=member, role=UserDomainRole.Roles.MANAGER)
for domain_id in added_domain_ids for domain in added_domains
], ],
ignore_conflicts=True, # Avoid duplicate entries ignore_conflicts=True, # Avoid duplicate entries
) )
@ -443,6 +457,7 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission
removed_domains = request.POST.get("removed_domains") removed_domains = request.POST.get("removed_domains")
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
email = portfolio_invitation.email email = portfolio_invitation.email
portfolio = portfolio_invitation.portfolio
added_domain_ids = self._parse_domain_ids(added_domains, "added domains") added_domain_ids = self._parse_domain_ids(added_domains, "added domains")
if added_domain_ids is None: if added_domain_ids is None:
@ -454,7 +469,7 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission
if added_domain_ids or removed_domain_ids: if added_domain_ids or removed_domain_ids:
try: try:
self._process_added_domains(added_domain_ids, email) self._process_added_domains(added_domain_ids, email, request.user, portfolio)
self._process_removed_domains(removed_domain_ids, email) self._process_removed_domains(removed_domain_ids, email)
messages.success(request, "The domain assignment changes have been saved.") messages.success(request, "The domain assignment changes have been saved.")
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
@ -464,15 +479,15 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission
"A database error occurred while saving changes. If the issue persists, " "A database error occurred while saving changes. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.", f"please contact {DefaultUserValues.HELP_EMAIL}.",
) )
logger.error("A database error occurred while saving changes.") logger.error("A database error occurred while saving changes.", exc_info=True)
return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk})) return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk}))
except Exception as e: except Exception as e:
messages.error( messages.error(
request, request,
"An unexpected error occurred: {str(e)}. If the issue persists, " f"An unexpected error occurred: {str(e)}. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.", f"please contact {DefaultUserValues.HELP_EMAIL}.",
) )
logger.error(f"An unexpected error occurred: {str(e)}.") logger.error(f"An unexpected error occurred: {str(e)}.", exc_info=True)
return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk})) return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk}))
else: else:
messages.info(request, "No changes detected.") messages.info(request, "No changes detected.")
@ -493,16 +508,24 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission
logger.error(f"Invalid data for {domain_type}.") logger.error(f"Invalid data for {domain_type}.")
return None return None
def _process_added_domains(self, added_domain_ids, email): def _process_added_domains(self, added_domain_ids, email, requestor, portfolio):
""" """
Processes added domain invitations by updating existing invitations Processes added domain invitations by updating existing invitations
or creating new ones. or creating new ones.
""" """
if not added_domain_ids: if added_domain_ids:
return # get added_domains from ids to pass to send email method and bulk create
added_domains = Domain.objects.filter(id__in=added_domain_ids)
member_of_a_different_org, _ = get_org_membership(portfolio, email, None)
send_domain_invitation_email(
email=email,
requestor=requestor,
domains=added_domains,
is_member_of_different_org=member_of_a_different_org,
)
# Update existing invitations from CANCELED to INVITED # Update existing invitations from CANCELED to INVITED
existing_invitations = DomainInvitation.objects.filter(domain_id__in=added_domain_ids, email=email) existing_invitations = DomainInvitation.objects.filter(domain__in=added_domains, email=email)
existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED) existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED)
# Determine which domains need new invitations # Determine which domains need new invitations
@ -754,7 +777,11 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
try: try:
if not requested_user or not permission_exists: if not requested_user or not permission_exists:
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
form.save() portfolio_invitation = form.save()
# if user exists for email, immediately retrieve portfolio invitation upon creation
if requested_user is not None:
portfolio_invitation.retrieve()
portfolio_invitation.save()
messages.success(self.request, f"{requested_email} has been invited.") messages.success(self.request, f"{requested_email} has been invited.")
else: else:
if permission_exists: if permission_exists:

View file

@ -0,0 +1,86 @@
from django.contrib import messages
from django.db import IntegrityError
from registrar.models import PortfolioInvitation, User, UserPortfolioPermission
from registrar.utility.email import EmailSendingError
import logging
from registrar.utility.errors import (
AlreadyDomainInvitedError,
AlreadyDomainManagerError,
MissingEmailError,
OutsideOrgMemberError,
)
logger = logging.getLogger(__name__)
# These methods are used by multiple views which share similar logic and function
# when creating invitations and sending associated emails. These can be reused in
# any view, and were initially developed for domain.py, portfolios.py and admin.py
def get_org_membership(org, email, user):
"""
Determines if an email/user belongs to a different organization or this organization
as either a member or an invited member.
This function returns a tuple (member_of_a_different_org, member_of_this_org),
which provides:
- member_of_a_different_org: True if the user/email is associated with an organization other than the given org.
- member_of_this_org: True if the user/email is associated with the given org.
Note: This implementation assumes single portfolio ownership for a user.
If the "multiple portfolios" feature is enabled, this logic may not account for
situations where a user or email belongs to multiple organizations.
"""
# Check for existing permissions or invitations for the user
existing_org_permission = UserPortfolioPermission.objects.filter(user=user).first()
existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first()
# Determine membership in a different organization
member_of_a_different_org = (existing_org_permission and existing_org_permission.portfolio != org) or (
existing_org_invitation and existing_org_invitation.portfolio != org
)
# Determine membership in the same organization
member_of_this_org = (existing_org_permission and existing_org_permission.portfolio == org) or (
existing_org_invitation and existing_org_invitation.portfolio == org
)
return member_of_a_different_org, member_of_this_org
def get_requested_user(email):
"""Retrieve a user by email or return None if the user doesn't exist."""
try:
return User.objects.get(email=email)
except User.DoesNotExist:
return None
def handle_invitation_exceptions(request, exception, email):
"""Handle exceptions raised during the process."""
if isinstance(exception, EmailSendingError):
logger.warning(str(exception), exc_info=True)
messages.error(request, str(exception))
elif isinstance(exception, MissingEmailError):
messages.error(request, str(exception))
logger.error(str(exception), exc_info=True)
elif isinstance(exception, OutsideOrgMemberError):
logger.warning(
"Could not send email. Can not invite member of a .gov organization to a different organization.",
exc_info=True,
)
messages.error(
request,
f"{email} is already a member of another .gov organization.",
)
elif isinstance(exception, AlreadyDomainManagerError):
messages.warning(request, str(exception))
elif isinstance(exception, AlreadyDomainInvitedError):
messages.warning(request, str(exception))
elif isinstance(exception, IntegrityError):
messages.warning(request, f"{email} is already a manager for this domain")
else:
logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.warning(request, "Could not send email invitation.")