mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-22 18:56:15 +02:00
Merge branch 'main' into ms/3316-automatically-add-portfolio-members
This commit is contained in:
commit
edda071691
19 changed files with 497 additions and 25 deletions
90
.github/workflows/delete-and-recreate-db.yaml
vendored
Normal file
90
.github/workflows/delete-and-recreate-db.yaml
vendored
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
# This workflow can be run from the CLI
|
||||||
|
# gh workflow run reset-db.yaml -f environment=ENVIRONMENT
|
||||||
|
|
||||||
|
name: Reset database
|
||||||
|
run-name: Reset database for ${{ github.event.inputs.environment }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
type: choice
|
||||||
|
description: Which environment should we flush and re-load data for?
|
||||||
|
options:
|
||||||
|
- el
|
||||||
|
- ad
|
||||||
|
- ms
|
||||||
|
- ag
|
||||||
|
- litterbox
|
||||||
|
- hotgov
|
||||||
|
- cb
|
||||||
|
- bob
|
||||||
|
- meoward
|
||||||
|
- backup
|
||||||
|
- ky
|
||||||
|
- es
|
||||||
|
- nl
|
||||||
|
- rh
|
||||||
|
- za
|
||||||
|
- gd
|
||||||
|
- rb
|
||||||
|
- ko
|
||||||
|
- ab
|
||||||
|
- rjm
|
||||||
|
- dk
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
reset-db:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
|
||||||
|
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
|
||||||
|
DESTINATION_ENVIRONMENT: ${{ github.event.inputs.environment}}
|
||||||
|
steps:
|
||||||
|
- name: Delete and Recreate Database
|
||||||
|
env:
|
||||||
|
cf_username: ${{ secrets[env.CF_USERNAME] }}
|
||||||
|
cf_password: ${{ secrets[env.CF_PASSWORD] }}
|
||||||
|
run: |
|
||||||
|
# install cf cli and other tools
|
||||||
|
wget -q -O - https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key | sudo gpg --dearmor -o /usr/share/keyrings/cli.cloudfoundry.org.gpg
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/cli.cloudfoundry.org.gpg] https://packages.cloudfoundry.org/debian stable main" | sudo tee /etc/apt/sources.list.d/cloudfoundry-cli.list
|
||||||
|
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install cf8-cli
|
||||||
|
cf api api.fr.cloud.gov
|
||||||
|
cf auth "$CF_USERNAME" "$CF_PASSWORD"
|
||||||
|
cf target -o cisa-dotgov -s $DESTINATION_ENVIRONMENT
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# unbind the service
|
||||||
|
cf unbind-service getgov-$DESTINATION_ENVIRONMENT getgov-$DESTINATION_ENVIRONMENT-database
|
||||||
|
#delete the service key
|
||||||
|
yes Y | cf delete-service-key getgov-$DESTINATION_ENVIRONMENT-database SERVICE_CONNECT
|
||||||
|
# delete the service
|
||||||
|
yes Y | cf delete-service getgov-$DESTINATION_ENVIRONMENT-database
|
||||||
|
# create it again
|
||||||
|
cf create-service aws-rds micro-psql getgov-$DESTINATION_ENVIRONMENT-database
|
||||||
|
# wait for it be created (up to 5 mins)
|
||||||
|
# this checks the creation cf service getgov-$DESTINATION_ENVIRONMENT-database
|
||||||
|
# the below command with check “status” line using cf service command mentioned above. if it says “create in progress” it will keep waiting otherwise the next steps fail
|
||||||
|
|
||||||
|
timeout 480 bash -c "until cf service getgov-$DESTINATION_ENVIRONMENT-database | grep -q 'The service instance status is succeeded'
|
||||||
|
do
|
||||||
|
echo 'Database not up yet, waiting...'
|
||||||
|
sleep 30
|
||||||
|
done"
|
||||||
|
|
||||||
|
# rebind the service
|
||||||
|
cf bind-service getgov-$DESTINATION_ENVIRONMENT getgov-$DESTINATION_ENVIRONMENT-database
|
||||||
|
#restage the app or it will not connect to the database right for the next commands
|
||||||
|
cf restage getgov-$DESTINATION_ENVIRONMENT
|
||||||
|
# wait for the above command to finish
|
||||||
|
# if it is taking way to long and the annoying “instance starting” line that keeps repeating, then run following two commands in a separate window. This will interrupt the death loop where it keeps hitting an error with it failing health checks
|
||||||
|
# create the cache table and run migrations
|
||||||
|
cf run-task getgov-$DESTINATION_ENVIRONMENT --command 'python manage.py createcachetable' --name createcachetable
|
||||||
|
cf run-task getgov-$DESTINATION_ENVIRONMENT --wait --command 'python manage.py migrate' --name migrate
|
||||||
|
|
||||||
|
# load fixtures
|
||||||
|
cf run-task getgov-$DESTINATION_ENVIRONMENT --wait --command 'python manage.py load' --name loaddata
|
7
docs/developer/workflows/README.md
Normal file
7
docs/developer/workflows/README.md
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# Workflows Docs
|
||||||
|
|
||||||
|
========================
|
||||||
|
|
||||||
|
This directory contains files related to workflows
|
||||||
|
|
||||||
|
Delete And Recreate Database is in [docs/ops](../workflows/delete-and-recreate-db.md/).
|
13
docs/developer/workflows/delete-and-recreate-db.md
Normal file
13
docs/developer/workflows/delete-and-recreate-db.md
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
## Delete And Recreate Database
|
||||||
|
|
||||||
|
This script destroys and recreates a database. This is another troubleshooting tool for issues with the database.
|
||||||
|
|
||||||
|
1. unbinds the database
|
||||||
|
2. deletes it
|
||||||
|
3. recreates it
|
||||||
|
4. binds it back to the sandbox
|
||||||
|
5. runs migrations
|
||||||
|
|
||||||
|
Addition Info in this slack thread:
|
||||||
|
|
||||||
|
- [Slack thread](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1725495150772119)
|
|
@ -1381,9 +1381,13 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
|
|
||||||
change_form_template = "django/admin/user_domain_role_change_form.html"
|
change_form_template = "django/admin/user_domain_role_change_form.html"
|
||||||
|
|
||||||
|
# Override for the delete confirmation page on the domain table (bulk delete action)
|
||||||
|
delete_selected_confirmation_template = "django/admin/user_domain_role_delete_selected_confirmation.html"
|
||||||
|
|
||||||
# Fixes a bug where non-superusers are redirected to the main page
|
# Fixes a bug where non-superusers are redirected to the main page
|
||||||
def delete_view(self, request, object_id, extra_context=None):
|
def delete_view(self, request, object_id, extra_context=None):
|
||||||
"""Custom delete_view implementation that specifies redirect behaviour"""
|
"""Custom delete_view implementation that specifies redirect behaviour"""
|
||||||
|
self.delete_confirmation_template = "django/admin/user_domain_role_delete_confirmation.html"
|
||||||
response = super().delete_view(request, object_id, extra_context)
|
response = super().delete_view(request, object_id, extra_context)
|
||||||
|
|
||||||
if isinstance(response, HttpResponseRedirect) and not request.user.has_perm("registrar.full_access_permission"):
|
if isinstance(response, HttpResponseRedirect) and not request.user.has_perm("registrar.full_access_permission"):
|
||||||
|
@ -1518,6 +1522,8 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
||||||
autocomplete_fields = ["domain"]
|
autocomplete_fields = ["domain"]
|
||||||
|
|
||||||
change_form_template = "django/admin/domain_invitation_change_form.html"
|
change_form_template = "django/admin/domain_invitation_change_form.html"
|
||||||
|
# Override for the delete confirmation page on the domain table (bulk delete action)
|
||||||
|
delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html"
|
||||||
|
|
||||||
# Select domain invitations to change -> Domain invitations
|
# Select domain invitations to change -> Domain invitations
|
||||||
def changelist_view(self, request, extra_context=None):
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
@ -1527,6 +1533,16 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
||||||
# Get the filtered values
|
# Get the filtered values
|
||||||
return super().changelist_view(request, extra_context=extra_context)
|
return super().changelist_view(request, extra_context=extra_context)
|
||||||
|
|
||||||
|
def delete_view(self, request, object_id, extra_context=None):
|
||||||
|
"""
|
||||||
|
Custom delete_view to perform additional actions or customize the template.
|
||||||
|
"""
|
||||||
|
# Set the delete template to a custom one
|
||||||
|
self.delete_confirmation_template = "django/admin/domain_invitation_delete_confirmation.html"
|
||||||
|
response = super().delete_view(request, object_id, extra_context=extra_context)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
"""
|
"""
|
||||||
Override the save_model method.
|
Override the save_model method.
|
||||||
|
|
|
@ -9,6 +9,7 @@ from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignor
|
||||||
from django.db import models, IntegrityError
|
from django.db import models, IntegrityError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from registrar.models.domain_invitation import DomainInvitation
|
||||||
from registrar.models.host import Host
|
from registrar.models.host import Host
|
||||||
from registrar.models.host_ip import HostIP
|
from registrar.models.host_ip import HostIP
|
||||||
from registrar.utility.enums import DefaultEmail
|
from registrar.utility.enums import DefaultEmail
|
||||||
|
@ -1177,6 +1178,10 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
return "DNS needed"
|
return "DNS needed"
|
||||||
return self.state.capitalize()
|
return self.state.capitalize()
|
||||||
|
|
||||||
|
def active_invitations(self):
|
||||||
|
"""Returns only the active invitations (those with status 'invited')."""
|
||||||
|
return self.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED)
|
||||||
|
|
||||||
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
|
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
|
||||||
"""Maps the Epp contact representation to a PublicContact object.
|
"""Maps the Epp contact representation to a PublicContact object.
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends 'admin/delete_confirmation.html' %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content_subtitle %}
|
||||||
|
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
|
||||||
|
<div class="usa-alert__body margin-left-1 maxw-none">
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
|
||||||
|
their domain management privileges if they already have that role assigned. Go to the
|
||||||
|
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
|
||||||
|
if you want to remove the user from a domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends 'admin/delete_selected_confirmation.html' %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content_subtitle %}
|
||||||
|
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
|
||||||
|
<div class="usa-alert__body margin-left-1 maxw-none">
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
|
||||||
|
their domain management privileges if they already have that role assigned. Go to the
|
||||||
|
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
|
||||||
|
if you want to remove the user from a domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% extends 'admin/delete_confirmation.html' %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content_subtitle %}
|
||||||
|
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
|
||||||
|
<div class="usa-alert__body margin-left-1 maxw-none">
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
If you remove someone from a domain here, it won't trigger any emails when you click "save."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% extends 'admin/delete_selected_confirmation.html' %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content_subtitle %}
|
||||||
|
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
|
||||||
|
<div class="usa-alert__body margin-left-1 maxw-none">
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
If you remove someone from a domain here, it won't trigger any emails when you click "save."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,27 @@
|
||||||
|
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||||
|
Hi,{% if domain_manager and domain_manager.first_name %} {{ domain_manager.first_name }}.{% endif %}
|
||||||
|
|
||||||
|
A domain manager was removed from {{ domain.name }}.
|
||||||
|
|
||||||
|
REMOVED BY: {{ removed_by.email }}
|
||||||
|
REMOVED ON: {{ date }}
|
||||||
|
MANAGER REMOVED: {{ manager_removed.email }}
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
WHY DID YOU RECEIVE THIS EMAIL?
|
||||||
|
You’re listed as a domain manager for {{ domain.name }}, so you’ll receive a notification whenever a domain manager is removed from that domain.
|
||||||
|
If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email.
|
||||||
|
|
||||||
|
THANK YOU
|
||||||
|
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
The .gov team
|
||||||
|
Contact us: <https://get.gov/contact/>
|
||||||
|
Learn about .gov <https://get.gov>
|
||||||
|
|
||||||
|
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
|
||||||
|
(CISA) <https://cisa.gov/>
|
||||||
|
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
||||||
|
A domain manager was removed from {{ domain.name }}
|
|
@ -113,10 +113,10 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if value.invitations.all %}
|
{% if value.active_invitations.all %}
|
||||||
<h4 class="margin-bottom-05">Invited domain managers</h4>
|
<h4 class="margin-bottom-05">Invited domain managers</h4>
|
||||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||||
{% for item in value.invitations.all %}
|
{% for item in value.active_invitations.all %}
|
||||||
<li>{{ item.email }}</li>
|
<li>{{ item.email }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -120,7 +120,7 @@ class TestFsmModelResource(TestCase):
|
||||||
fsm_field_mock.save.assert_not_called()
|
fsm_field_mock.save.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestDomainInvitationAdmin(TestCase):
|
class TestDomainInvitationAdmin(WebTest):
|
||||||
"""Tests for the DomainInvitationAdmin class as super user
|
"""Tests for the DomainInvitationAdmin class as super user
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
@ -128,15 +128,27 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
tests have available superuser, client, and admin
|
tests have available superuser, client, and admin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
# csrf checks do not work with WebTest.
|
||||||
|
# We disable them here. TODO for another ticket.
|
||||||
|
csrf_checks = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(self):
|
||||||
|
super().setUpClass()
|
||||||
|
self.site = AdminSite()
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
|
|
||||||
self.superuser = create_superuser()
|
self.superuser = create_superuser()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
|
||||||
self.domain = Domain.objects.create(name="example.com")
|
self.domain = Domain.objects.create(name="example.com")
|
||||||
self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser)
|
self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser)
|
||||||
DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser)
|
DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser)
|
||||||
"""Create a client object"""
|
"""Create a client object"""
|
||||||
self.client = Client(HTTP_HOST="localhost:8080")
|
self.client = Client(HTTP_HOST="localhost:8080")
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
self.app.set_user(self.superuser.username)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
"""Delete all DomainInvitation objects"""
|
"""Delete all DomainInvitation objects"""
|
||||||
|
@ -1071,6 +1083,50 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
self.assertEqual(DomainInvitation.objects.count(), 0)
|
self.assertEqual(DomainInvitation.objects.count(), 0)
|
||||||
self.assertEqual(PortfolioInvitation.objects.count(), 1)
|
self.assertEqual(PortfolioInvitation.objects.count(), 1)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_custom_delete_confirmation_page(self):
|
||||||
|
"""Tests if custom alerts display on Domain Invitation delete page"""
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
self.app.set_user(self.superuser.username)
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
|
||||||
|
domain_invitation, _ = DomainInvitation.objects.get_or_create(domain=domain)
|
||||||
|
|
||||||
|
domain_invitation_change_page = self.app.get(
|
||||||
|
reverse("admin:registrar_domaininvitation_change", args=[domain_invitation.pk])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(domain_invitation_change_page, "domain-invitation-test.gov")
|
||||||
|
# click the "Delete" link
|
||||||
|
confirmation_page = domain_invitation_change_page.click("Delete", index=0)
|
||||||
|
|
||||||
|
custom_alert_content = "If you cancel the domain invitation here"
|
||||||
|
self.assertContains(confirmation_page, custom_alert_content)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_custom_selected_delete_confirmation_page(self):
|
||||||
|
"""Tests if custom alerts display on Domain Invitation selected delete page from Domain Invitation table"""
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
|
||||||
|
domain_invitation, _ = DomainInvitation.objects.get_or_create(domain=domain)
|
||||||
|
|
||||||
|
# Get the index. The post expects the index to be encoded as a string
|
||||||
|
index = f"{domain_invitation.id}"
|
||||||
|
|
||||||
|
test_helper = GenericTestHelper(
|
||||||
|
factory=self.factory,
|
||||||
|
user=self.superuser,
|
||||||
|
admin=self.admin,
|
||||||
|
url=reverse("admin:registrar_domaininvitation_changelist"),
|
||||||
|
model=Domain,
|
||||||
|
client=self.client,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate selecting a single record, then clicking "Delete selected domains"
|
||||||
|
response = test_helper.get_table_delete_confirmation_page("0", index)
|
||||||
|
|
||||||
|
# Check for custom alert message
|
||||||
|
custom_alert_content = "If you cancel the domain invitation here"
|
||||||
|
self.assertContains(response, custom_alert_content)
|
||||||
|
|
||||||
|
|
||||||
class TestUserPortfolioPermissionAdmin(TestCase):
|
class TestUserPortfolioPermissionAdmin(TestCase):
|
||||||
"""Tests for the PortfolioInivtationAdmin class"""
|
"""Tests for the PortfolioInivtationAdmin class"""
|
||||||
|
@ -2016,7 +2072,7 @@ class TestDomainInformationAdmin(TestCase):
|
||||||
self.test_helper.assert_table_sorted("-4", ("-creator__first_name", "-creator__last_name"))
|
self.test_helper.assert_table_sorted("-4", ("-creator__first_name", "-creator__last_name"))
|
||||||
|
|
||||||
|
|
||||||
class TestUserDomainRoleAdmin(TestCase):
|
class TestUserDomainRoleAdmin(WebTest):
|
||||||
"""Tests for the UserDomainRoleAdmin class as super user
|
"""Tests for the UserDomainRoleAdmin class as super user
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
@ -2043,6 +2099,8 @@ class TestUserDomainRoleAdmin(TestCase):
|
||||||
"""Setup environment for a mock admin user"""
|
"""Setup environment for a mock admin user"""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.client = Client(HTTP_HOST="localhost:8080")
|
self.client = Client(HTTP_HOST="localhost:8080")
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
self.app.set_user(self.superuser.username)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
"""Delete all Users, Domains, and UserDomainRoles"""
|
"""Delete all Users, Domains, and UserDomainRoles"""
|
||||||
|
@ -2205,6 +2263,48 @@ class TestUserDomainRoleAdmin(TestCase):
|
||||||
# We only need to check for the end of the HTML string
|
# We only need to check for the end of the HTML string
|
||||||
self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com</a></th>", count=1)
|
self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com</a></th>", count=1)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_custom_delete_confirmation_page(self):
|
||||||
|
"""Tests if custom alerts display on User Domain Role delete page"""
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="user-domain-role-test.gov", state=Domain.State.READY)
|
||||||
|
domain_role, _ = UserDomainRole.objects.get_or_create(domain=domain, user=self.superuser)
|
||||||
|
|
||||||
|
domain_invitation_change_page = self.app.get(
|
||||||
|
reverse("admin:registrar_userdomainrole_change", args=[domain_role.pk])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(domain_invitation_change_page, "user-domain-role-test.gov")
|
||||||
|
# click the "Delete" link
|
||||||
|
confirmation_page = domain_invitation_change_page.click("Delete", index=0)
|
||||||
|
|
||||||
|
custom_alert_content = "If you remove someone from a domain here"
|
||||||
|
self.assertContains(confirmation_page, custom_alert_content)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_custom_selected_delete_confirmation_page(self):
|
||||||
|
"""Tests if custom alerts display on selected delete page from User Domain Roles table"""
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
|
||||||
|
domain_role, _ = UserDomainRole.objects.get_or_create(domain=domain, user=self.superuser)
|
||||||
|
|
||||||
|
# Get the index. The post expects the index to be encoded as a string
|
||||||
|
index = f"{domain_role.id}"
|
||||||
|
|
||||||
|
test_helper = GenericTestHelper(
|
||||||
|
factory=self.factory,
|
||||||
|
user=self.superuser,
|
||||||
|
admin=self.admin,
|
||||||
|
url=reverse("admin:registrar_userdomainrole_changelist"),
|
||||||
|
model=Domain,
|
||||||
|
client=self.client,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate selecting a single record, then clicking "Delete selected domains"
|
||||||
|
response = test_helper.get_table_delete_confirmation_page("0", index)
|
||||||
|
|
||||||
|
# Check for custom alert message
|
||||||
|
custom_alert_content = "If you remove someone from a domain here"
|
||||||
|
self.assertContains(response, custom_alert_content)
|
||||||
|
|
||||||
|
|
||||||
class TestListHeaderAdmin(TestCase):
|
class TestListHeaderAdmin(TestCase):
|
||||||
"""Tests for the ListHeaderAdmin class as super user
|
"""Tests for the ListHeaderAdmin class as super user
|
||||||
|
|
|
@ -1106,7 +1106,7 @@ class TestDomainRequest(TestCase):
|
||||||
federal_agency=fed_agency,
|
federal_agency=fed_agency,
|
||||||
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
|
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||||
)
|
)
|
||||||
user_portfolio_permission = UserPortfolioPermission.objects.create( # noqa: F841
|
UserPortfolioPermission.objects.create(
|
||||||
user=self.dummy_user_3, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
user=self.dummy_user_3, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
)
|
)
|
||||||
# Adds cc'ed email in this test's allow list
|
# Adds cc'ed email in this test's allow list
|
||||||
|
|
|
@ -1063,6 +1063,23 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
success_page = success_result.follow()
|
success_page = success_result.follow()
|
||||||
self.assertContains(success_page, "Failed to send email.")
|
self.assertContains(success_page, "Failed to send email.")
|
||||||
|
|
||||||
|
@boto3_mocking.patching
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.views.domain.send_templated_email")
|
||||||
|
def test_domain_remove_manager(self, mock_send_templated_email):
|
||||||
|
"""Removing a domain manager sends notification email to other domain managers."""
|
||||||
|
self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
|
||||||
|
self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
|
||||||
|
self.client.post(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id}))
|
||||||
|
|
||||||
|
# Verify that the notification emails were sent to domain manager
|
||||||
|
mock_send_templated_email.assert_called_once_with(
|
||||||
|
"emails/domain_manager_deleted_notification.txt",
|
||||||
|
"emails/domain_manager_deleted_notification_subject.txt",
|
||||||
|
to_address="info@example.com",
|
||||||
|
context=ANY,
|
||||||
|
)
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@patch("registrar.views.domain.send_domain_invitation_email")
|
@patch("registrar.views.domain.send_domain_invitation_email")
|
||||||
def test_domain_invitation_created(self, mock_send_domain_email):
|
def test_domain_invitation_created(self, mock_send_domain_email):
|
||||||
|
|
|
@ -372,6 +372,21 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
|
||||||
domain=domain3,
|
domain=domain3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# create another domain in the portfolio
|
||||||
|
# but make sure the domain invitation is canceled
|
||||||
|
domain4 = Domain.objects.create(
|
||||||
|
name="somedomain4.com",
|
||||||
|
)
|
||||||
|
DomainInformation.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
domain=domain4,
|
||||||
|
)
|
||||||
|
DomainInvitation.objects.create(
|
||||||
|
email=self.email6,
|
||||||
|
domain=domain4,
|
||||||
|
status=DomainInvitation.DomainInvitationStatus.CANCELED,
|
||||||
|
)
|
||||||
|
|
||||||
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json
|
data = response.json
|
||||||
|
@ -381,6 +396,7 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
|
||||||
self.assertIn("somedomain1.com", domain_names)
|
self.assertIn("somedomain1.com", domain_names)
|
||||||
self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names)
|
self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names)
|
||||||
self.assertNotIn("somedomain3.com", domain_names)
|
self.assertNotIn("somedomain3.com", domain_names)
|
||||||
|
self.assertNotIn("somedomain4.com", domain_names)
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
|
|
|
@ -22,7 +22,7 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho
|
||||||
from registrar.tests.test_views import TestWithUser
|
from registrar.tests.test_views import TestWithUser
|
||||||
from registrar.utility.email import EmailSendingError
|
from registrar.utility.email import EmailSendingError
|
||||||
from registrar.utility.errors import MissingEmailError
|
from registrar.utility.errors import MissingEmailError
|
||||||
from .common import MockSESClient, completed_domain_request, create_test_user, create_user
|
from .common import MockEppLib, MockSESClient, completed_domain_request, create_test_user, create_user
|
||||||
from waffle.testutils import override_flag
|
from waffle.testutils import override_flag
|
||||||
from django.contrib.sessions.middleware import SessionMiddleware
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
import boto3_mocking # type: ignore
|
import boto3_mocking # type: ignore
|
||||||
|
@ -3365,34 +3365,35 @@ class TestRequestingEntity(WebTest):
|
||||||
self.assertContains(response, "kepler, AL")
|
self.assertContains(response, "kepler, AL")
|
||||||
|
|
||||||
|
|
||||||
class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
class TestPortfolioInviteNewMemberView(MockEppLib, WebTest):
|
||||||
|
|
||||||
@classmethod
|
def setUp(self):
|
||||||
def setUpClass(cls):
|
super().setUp()
|
||||||
super().setUpClass()
|
|
||||||
|
self.user = create_test_user()
|
||||||
|
|
||||||
# Create Portfolio
|
# Create Portfolio
|
||||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
|
||||||
|
|
||||||
# Add an invited member who has been invited to manage domains
|
# Add an invited member who has been invited to manage domains
|
||||||
cls.invited_member_email = "invited@example.com"
|
self.invited_member_email = "invited@example.com"
|
||||||
cls.invitation = PortfolioInvitation.objects.create(
|
self.invitation = PortfolioInvitation.objects.create(
|
||||||
email=cls.invited_member_email,
|
email=self.invited_member_email,
|
||||||
portfolio=cls.portfolio,
|
portfolio=self.portfolio,
|
||||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
additional_permissions=[
|
additional_permissions=[
|
||||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.new_member_email = "newmember@example.com"
|
self.new_member_email = "newmember@example.com"
|
||||||
|
|
||||||
AllowedEmail.objects.get_or_create(email=cls.new_member_email)
|
AllowedEmail.objects.get_or_create(email=self.new_member_email)
|
||||||
|
|
||||||
# Assign permissions to the user making requests
|
# Assign permissions to the user making requests
|
||||||
UserPortfolioPermission.objects.create(
|
UserPortfolioPermission.objects.create(
|
||||||
user=cls.user,
|
user=self.user,
|
||||||
portfolio=cls.portfolio,
|
portfolio=self.portfolio,
|
||||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
additional_permissions=[
|
additional_permissions=[
|
||||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
@ -3400,14 +3401,13 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
def tearDown(self):
|
||||||
def tearDownClass(cls):
|
|
||||||
PortfolioInvitation.objects.all().delete()
|
PortfolioInvitation.objects.all().delete()
|
||||||
UserPortfolioPermission.objects.all().delete()
|
UserPortfolioPermission.objects.all().delete()
|
||||||
Portfolio.objects.all().delete()
|
Portfolio.objects.all().delete()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
AllowedEmail.objects.all().delete()
|
AllowedEmail.objects.all().delete()
|
||||||
super().tearDownClass()
|
super().tearDown()
|
||||||
|
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
@ -3452,6 +3452,85 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
||||||
# 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)
|
||||||
|
|
||||||
|
@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_previously_removed_user(self, mock_send_email):
|
||||||
|
"""Tests the member invitation flow for an existing member which was previously removed."""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
# invite, then retrieve an existing user, then remove the user from the portfolio
|
||||||
|
retrieved_member_email = "retrieved@example.com"
|
||||||
|
retrieved_user = User.objects.create(
|
||||||
|
username="retrieved_user",
|
||||||
|
first_name="Retrieved",
|
||||||
|
last_name="User",
|
||||||
|
email=retrieved_member_email,
|
||||||
|
phone="8003111234",
|
||||||
|
title="retrieved",
|
||||||
|
)
|
||||||
|
|
||||||
|
retrieved_invitation = PortfolioInvitation.objects.create(
|
||||||
|
email=retrieved_member_email,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
],
|
||||||
|
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED,
|
||||||
|
)
|
||||||
|
retrieved_invitation.retrieve()
|
||||||
|
retrieved_invitation.save()
|
||||||
|
upp = UserPortfolioPermission.objects.filter(
|
||||||
|
user=retrieved_user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
)
|
||||||
|
upp.delete()
|
||||||
|
|
||||||
|
# Simulate a session to ensure continuity
|
||||||
|
session_id = self.client.session.session_key
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
|
||||||
|
# Simulate submission of member invite for previously retrieved/removed member
|
||||||
|
final_response = self.client.post(
|
||||||
|
reverse("new-member"),
|
||||||
|
{
|
||||||
|
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||||
|
"domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||||
|
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
|
||||||
|
"member_permissions": "no_access",
|
||||||
|
"email": retrieved_member_email,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the final submission is successful
|
||||||
|
self.assertEqual(final_response.status_code, 302) # Redirects
|
||||||
|
|
||||||
|
# Validate Database Changes
|
||||||
|
# Validate that portfolio invitation was created and retrieved
|
||||||
|
self.assertFalse(
|
||||||
|
PortfolioInvitation.objects.filter(
|
||||||
|
email=retrieved_member_email,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
# at least one retrieved invitation
|
||||||
|
self.assertTrue(
|
||||||
|
PortfolioInvitation.objects.filter(
|
||||||
|
email=retrieved_member_email,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
# Ensure exactly one UserPortfolioPermission exists for the retrieved user
|
||||||
|
self.assertEqual(
|
||||||
|
UserPortfolioPermission.objects.filter(user=retrieved_user, portfolio=self.portfolio).count(),
|
||||||
|
1,
|
||||||
|
"Expected exactly one UserPortfolioPermission for the retrieved user.",
|
||||||
|
)
|
||||||
|
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
|
|
|
@ -1348,10 +1348,49 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
|
||||||
# Delete the object
|
# Delete the object
|
||||||
super().form_valid(form)
|
super().form_valid(form)
|
||||||
|
|
||||||
|
# Email all domain managers that domain manager has been removed
|
||||||
|
domain = self.object.domain
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"domain": domain,
|
||||||
|
"removed_by": self.request.user,
|
||||||
|
"manager_removed": self.object.user,
|
||||||
|
"date": date.today(),
|
||||||
|
"changes": "Domain Manager",
|
||||||
|
}
|
||||||
|
self.email_domain_managers(
|
||||||
|
domain,
|
||||||
|
"emails/domain_manager_deleted_notification.txt",
|
||||||
|
"emails/domain_manager_deleted_notification_subject.txt",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
# Add a success message
|
# Add a success message
|
||||||
messages.success(self.request, self.get_success_message())
|
messages.success(self.request, self.get_success_message())
|
||||||
return redirect(self.get_success_url())
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
|
||||||
|
manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list(
|
||||||
|
"user", flat=True
|
||||||
|
)
|
||||||
|
emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True))
|
||||||
|
|
||||||
|
for email in emails:
|
||||||
|
try:
|
||||||
|
send_templated_email(
|
||||||
|
template,
|
||||||
|
subject_template,
|
||||||
|
to_address=email,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
except EmailSendingError:
|
||||||
|
logger.warning(
|
||||||
|
"Could not send notification email to %s for domain %s",
|
||||||
|
email,
|
||||||
|
domain.name,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Custom post implementation to ensure last userdomainrole is not removed and to
|
"""Custom post implementation to ensure last userdomainrole is not removed and to
|
||||||
redirect to home in the event that the user deletes themselves"""
|
redirect to home in the event that the user deletes themselves"""
|
||||||
|
|
|
@ -123,7 +123,11 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
|
|
||||||
# Subquery to get concatenated domain information for each email
|
# Subquery to get concatenated domain information for each email
|
||||||
domain_invitations = (
|
domain_invitations = (
|
||||||
DomainInvitation.objects.filter(email=OuterRef("email"), domain__domain_info__portfolio=portfolio)
|
DomainInvitation.objects.filter(
|
||||||
|
email=OuterRef("email"),
|
||||||
|
domain__domain_info__portfolio=portfolio,
|
||||||
|
status=DomainInvitation.DomainInvitationStatus.INVITED,
|
||||||
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
concatenated_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())
|
concatenated_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())
|
||||||
)
|
)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue