Skip to content

Commit

Permalink
Merge branch 'main' into za/3299-script-to-update-suborg-values
Browse files Browse the repository at this point in the history
  • Loading branch information
zandercymatics committed Jan 16, 2025
2 parents 4612154 + 2ff8564 commit fcac8a8
Show file tree
Hide file tree
Showing 28 changed files with 2,460 additions and 602 deletions.
28 changes: 28 additions & 0 deletions docs/developer/registry-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

### 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.
237 changes: 142 additions & 95 deletions src/registrar/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from registrar.models.federal_agency import FederalAgency
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.utility.admin_helpers import (
AutocompleteSelectWithPlaceholder,
get_action_needed_reason_default_email,
Expand All @@ -27,8 +28,12 @@
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
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.views.utility.invitation_helper import (
get_org_membership,
get_requested_user,
handle_invitation_exceptions,
)
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
Expand All @@ -41,7 +46,7 @@
from waffle.models import Sample, Switch
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
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.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR
Expand Down Expand Up @@ -1389,7 +1394,78 @@ def changeform_view(self, request, object_id=None, form_url="", extra_context=No
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."""

class Meta:
Expand Down Expand Up @@ -1442,14 +1518,60 @@ def save_model(self, request, obj, form, change):
which will be successful if a single User exists for that email; otherwise, will
just continue to create the invitation.
"""
if not change and User.objects.filter(email=obj.email).count() == 1:
# Domain Invitation creation for an existing User
obj.retrieve()
# Call the parent save method to save the object
super().save_model(request, obj, form, change)
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
)

class PortfolioInvitationAdmin(ListHeaderAdmin):
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
obj.retrieve()
# Call the parent save method to save the object
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(BaseInvitationAdmin):
"""Custom portfolio invitation admin class."""

form = PortfolioInvitationAdminForm
Expand All @@ -1472,7 +1594,7 @@ class Meta:
# Search
search_fields = [
"email",
"portfolio__name",
"portfolio__organization_name",
]

# Filters
Expand Down Expand Up @@ -1510,6 +1632,8 @@ def save_model(self, request, obj, form, change):
portfolio = obj.portfolio
requested_email = obj.email
requestor = request.user
# Look up a user with that email
requested_user = get_requested_user(requested_email)

permission_exists = UserPortfolioPermission.objects.filter(
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
Expand All @@ -1518,98 +1642,19 @@ def save_model(self, request, obj, form, change):
if not permission_exists:
# if permission does not exist for a user with requested_email, send email
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.")
else:
messages.warning(request, "User is already a member of this portfolio.")
except Exception as e:
# 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
# Call the parent save method to save the object
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):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
Expand Down Expand Up @@ -2782,7 +2827,9 @@ def change_view(self, request, object_id, form_url="", extra_context=None):

try:
# 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
for log_entry in audit_log_entries:
Expand Down
2 changes: 1 addition & 1 deletion src/registrar/assets/src/sass/_theme/_tooltips.scss
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@
text-align: center;
font-size: inherit; //inherit tooltip fontsize of .93rem
max-width: fit-content;
display: block;
@include at-media('desktop') {
width: 70vw;
}
display: block;
}
}
5 changes: 5 additions & 0 deletions src/registrar/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,11 @@
views.DomainSecurityEmailView.as_view(),
name="domain-security-email",
),
path(
"domain/<int:pk>/renewal",
views.DomainRenewalView.as_view(),
name="domain-renewal",
),
path(
"domain/<int:pk>/users/add",
views.DomainAddUserView.as_view(),
Expand Down
1 change: 1 addition & 0 deletions src/registrar/forms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
DomainDsdataFormset,
DomainDsdataForm,
DomainSuborganizationForm,
DomainRenewalForm,
)
from .portfolio import (
PortfolioOrgAddressForm,
Expand Down
12 changes: 12 additions & 0 deletions src/registrar/forms/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,3 +661,15 @@ def clean(self):
extra=0,
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."
},
)
Loading

0 comments on commit fcac8a8

Please sign in to comment.