diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0f0c76ee3..06731db38 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1480,8 +1480,9 @@ class DomainInvitationAdmin(ListHeaderAdmin): send_domain_invitation_email( email=requested_email, requestor=requestor, - domain=domain, + 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 diff --git a/src/registrar/templates/emails/domain_invitation.txt b/src/registrar/templates/emails/domain_invitation.txt index 068040205..4959f7c23 100644 --- a/src/registrar/templates/emails/domain_invitation.txt +++ b/src/registrar/templates/emails/domain_invitation.txt @@ -1,36 +1,46 @@ {% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} -Hi. +{% if requested_user and requested_user.first_name %} +Hi, {{ requested_user.first_name }}. +{% else %} +Hi, +{% 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 }} +{% endfor %} -You can manage this domain on the .gov registrar . +To manage domain information, visit the .gov registrar . ---------------------------------------------------------------- +{% if not requested_user %} YOU NEED A LOGIN.GOV ACCOUNT -You’ll need a Login.gov account to manage your .gov domain. Login.gov provides -a simple and secure process for signing in to many government services with one -account. +You’ll need a Login.gov account to access the .gov registrar. That account needs to be +associated with the following email address: {{ invitee_email_address }} -If you don’t already have one, follow these steps to create your -Login.gov account . +Login.gov provides a simple and secure process for signing in to many government +services with one account. If you don’t already have one, follow these steps to create +your Login.gov account . +{% endif %} DOMAIN MANAGEMENT -As a .gov domain manager, you can add or update information about your domain. -You’ll also serve as a contact for your .gov domain. Please keep your contact -information updated. +As a .gov domain manager, you can add or update information like name servers. You’ll +also serve as a contact for the domains you manage. Please keep your contact +information updated. Learn more about domain management . SOMETHING WRONG? -If you’re not affiliated with {{ domain.name }} or think you received this -message in error, reply to this email. +If you’re not affiliated with the .gov domains mentioned in this invitation or think you +received this message in error, reply to this email. 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 +48,6 @@ The .gov team Contact us: Learn about .gov -The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency +(CISA) {% endautoescape %} diff --git a/src/registrar/templates/emails/domain_invitation_subject.txt b/src/registrar/templates/emails/domain_invitation_subject.txt index 319b80176..9663346d0 100644 --- a/src/registrar/templates/emails/domain_invitation_subject.txt +++ b/src/registrar/templates/emails/domain_invitation_subject.txt @@ -1 +1 @@ -You’ve been added to a .gov domain \ No newline at end of file +You've been invited to manage {% if domains|length > 1 %}.gov domains{% else %}{{ domains.0.name }}{% endif %} \ No newline at end of file diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py index 9455c5927..630da7ce5 100644 --- a/src/registrar/utility/email_invitations.py +++ b/src/registrar/utility/email_invitations.py @@ -1,5 +1,7 @@ from django.conf import settings from registrar.models import DomainInvitation +from registrar.models.domain import Domain +from registrar.models.user import User from registrar.utility.errors import ( AlreadyDomainInvitedError, AlreadyDomainManagerError, @@ -13,7 +15,7 @@ import logging 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. @@ -22,8 +24,9 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif Args: email (str): Email address of the recipient. 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 + requested_user (User | None): The recipient if the email belongs to a user in the registrar Raises: MissingEmailError: If the requestor has no email associated with their account. @@ -32,13 +35,19 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif OutsideOrgMemberError: If the requested_user is part of a different organization. EmailSendingError: If there is an error while sending the email. """ + print('send_domain_invitation_email') + # Normalize domains + if isinstance(domains, Domain): + domains = [domains] + # Default email address for staff requestor_email = 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() == "": - raise MissingEmailError(email=email, domain=domain) + domain_names = ", ".join([domain.name for domain in domains]) + raise MissingEmailError(email=email, domain=domain_names) else: requestor_email = requestor.email @@ -51,18 +60,19 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif ): raise OutsideOrgMemberError - # Check for an existing invitation - try: - invite = DomainInvitation.objects.get(email=email, domain=domain) - if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: - raise AlreadyDomainManagerError(email) - elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED: - invite.update_cancellation_status() - invite.save() - else: - raise AlreadyDomainInvitedError(email) - except DomainInvitation.DoesNotExist: - pass + # Check for an existing invitation for each domain + for domain in domains: + try: + invite = DomainInvitation.objects.get(email=email, domain=domain) + if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: + raise AlreadyDomainManagerError(email) + elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED: + invite.update_cancellation_status() + invite.save() + else: + raise AlreadyDomainInvitedError(email) + except DomainInvitation.DoesNotExist: + pass # Send the email try: @@ -71,12 +81,18 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif "emails/domain_invitation_subject.txt", to_address=email, context={ - "domain": domain, + "domains": domains, "requestor_email": requestor_email, + "invitee_email_address": email, + "requested_user": requested_user, }, ) except EmailSendingError as err: - raise EmailSendingError(f"Could not send email invitation to {email} for domain {domain}.") from err + print('point of failure test') + 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): diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index b8464464e..f7938a301 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -1204,7 +1204,7 @@ class DomainAddUserView(DomainFormBaseView): send_domain_invitation_email( email=email, requestor=requestor, - domain=self.object, + domains=self.object, is_member_of_different_org=member_of_different_org, ) DomainInvitation.objects.get_or_create(email=email, domain=self.object) @@ -1215,8 +1215,9 @@ class DomainAddUserView(DomainFormBaseView): send_domain_invitation_email( email=email, requestor=requestor, - domain=self.object, + domains=self.object, is_member_of_different_org=member_of_different_org, + requested_user=requested_user, ) UserDomainRole.objects.create( user=requested_user, diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 60b30ad60..0cca1280c 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -8,13 +8,14 @@ from django.utils.safestring import mark_safe from django.contrib import messages from registrar.forms import portfolio as portfolioForms from registrar.models import Portfolio, User +from registrar.models.domain import Domain from registrar.models.domain_invitation import DomainInvitation from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_portfolio_permission import UserPortfolioPermission 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.utility.errors import MissingEmailError from registrar.utility.enums import DefaultUserValues 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.db import IntegrityError +from registrar.views.utility.portfolio_helper import get_org_membership + logger = logging.getLogger(__name__) @@ -237,6 +240,7 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V removed_domains = request.POST.get("removed_domains") portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) member = portfolio_permission.user + portfolio = portfolio_permission.portfolio added_domain_ids = self._parse_domain_ids(added_domains, "added domains") if added_domain_ids is None: @@ -248,7 +252,7 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V if added_domain_ids or removed_domain_ids: 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) messages.success(request, "The domain assignment changes have been saved.") return redirect(reverse("member-domains", kwargs={"pk": pk})) @@ -263,7 +267,7 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V except Exception as e: messages.error( 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}.", ) logger.error(f"An unexpected error occurred: {str(e)}") @@ -287,16 +291,26 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V logger.error(f"Invalid data for {domain_type}") 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. """ if added_domain_ids: + print('_process_added_domains') + 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 UserDomainRole.objects.bulk_create( [ - UserDomainRole(domain_id=domain_id, user=member, role=UserDomainRole.Roles.MANAGER) - for domain_id in added_domain_ids + UserDomainRole(domain=domain, user=member, role=UserDomainRole.Roles.MANAGER) + for domain in added_domains ], ignore_conflicts=True, # Avoid duplicate entries ) @@ -443,6 +457,7 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission removed_domains = request.POST.get("removed_domains") portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) email = portfolio_invitation.email + portfolio = portfolio_invitation.portfolio added_domain_ids = self._parse_domain_ids(added_domains, "added domains") if added_domain_ids is None: @@ -454,7 +469,7 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission if added_domain_ids or removed_domain_ids: 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) messages.success(request, "The domain assignment changes have been saved.") return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) @@ -469,7 +484,7 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission except Exception as e: messages.error( 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}.", ) logger.error(f"An unexpected error occurred: {str(e)}.") @@ -493,34 +508,41 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission logger.error(f"Invalid data for {domain_type}.") 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 or creating new ones. """ - if not added_domain_ids: - return - - # Update existing invitations from CANCELED to INVITED - existing_invitations = DomainInvitation.objects.filter(domain_id__in=added_domain_ids, email=email) - existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED) - - # Determine which domains need new invitations - existing_domain_ids = existing_invitations.values_list("domain_id", flat=True) - new_domain_ids = set(added_domain_ids) - set(existing_domain_ids) - - # Bulk create new invitations - DomainInvitation.objects.bulk_create( - [ - DomainInvitation( - domain_id=domain_id, + if added_domain_ids: + 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, - status=DomainInvitation.DomainInvitationStatus.INVITED, + requestor=requestor, + domains=added_domains, + is_member_of_different_org=member_of_a_different_org, ) - for domain_id in new_domain_ids - ] - ) + # Update existing invitations from CANCELED to INVITED + existing_invitations = DomainInvitation.objects.filter(domain__in=added_domains, email=email) + existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED) + + # Determine which domains need new invitations + existing_domain_ids = existing_invitations.values_list("domain_id", flat=True) + new_domain_ids = set(added_domain_ids) - set(existing_domain_ids) + + # Bulk create new invitations + DomainInvitation.objects.bulk_create( + [ + DomainInvitation( + domain_id=domain_id, + email=email, + status=DomainInvitation.DomainInvitationStatus.INVITED, + ) + for domain_id in new_domain_ids + ] + ) + def _process_removed_domains(self, removed_domain_ids, email): """ Processes removed domain invitations by updating their status to CANCELED. @@ -755,8 +777,9 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin): if not requested_user or not permission_exists: send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) portfolio_invitation = form.save() - portfolio_invitation.retrieve() - portfolio_invitation.save() + if requested_user is not None: + portfolio_invitation.retrieve() + portfolio_invitation.save() messages.success(self.request, f"{requested_email} has been invited.") else: if permission_exists: