diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 8a07b3f27..6be718c23 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -912,6 +912,15 @@ function setupUrbanizationToggle(stateTerritoryField) {
HookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null)
})();
+
+/**
+ * An IIFE that listens to the yes/no radio buttons on the anything else form and toggles form field visibility accordingly
+ *
+ */
+(function newMemberFormListener() {
+ HookupYesNoListener("new_member-permission_level",'new-member-admin-permissions', 'new-member-basic-permissions')
+})();
+
/**
* An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms
*
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index 4d1be6f31..26c3f516e 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -86,11 +86,11 @@ urlpatterns = [
views.PortfolioMembersView.as_view(),
name="members",
),
- # path(
- # "no-organization-members/",
- # views.PortfolioNoMembersView.as_view(),
- # name="no-portfolio-members",
- # ),
+ path(
+ "members/new-member/",
+ views.NewMemberView.as_view(),
+ name="new-member",
+ ),
path(
"requests/",
views.PortfolioDomainRequestsView.as_view(),
diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py
index 999d76d51..4bc7ec046 100644
--- a/src/registrar/forms/portfolio.py
+++ b/src/registrar/forms/portfolio.py
@@ -3,8 +3,11 @@
import logging
from django import forms
from django.core.validators import RegexValidator
+from django.core.validators import MaxLengthValidator
-from ..models import DomainInformation, Portfolio, SeniorOfficial
+from registrar.models.user_portfolio_permission import UserPortfolioPermission
+
+from ..models import DomainInformation, Portfolio, SeniorOfficial, User
logger = logging.getLogger(__name__)
@@ -99,3 +102,69 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
cleaned_data = super().clean()
cleaned_data.pop("full_name", None)
return cleaned_data
+
+
+class NewMemberForm(forms.ModelForm):
+ admin_org_domain_request_permissions = forms.ChoiceField(
+ label="Select permission",
+ choices=[('view_only', 'View all requests'), ('view_and_create', 'View all requests plus create requests')],
+ widget=forms.RadioSelect,
+ required=True)
+ admin_org_members_permissions = forms.ChoiceField(
+ label="Select permission", choices=[('view_only', 'View all members'), ('view_and_create', 'View all members plus manage members')], widget=forms.RadioSelect, required=True)
+ basic_org_domain_request_permissions = forms.ChoiceField(
+ label="Select permission", choices=[('view_only', 'View all requests'), ('view_and_create', 'View all requests plus create requests'),('no_access', 'No access')], widget=forms.RadioSelect, required=True)
+
+ email = forms.EmailField(
+ label="Enter the email of the member you'd like to invite",
+ max_length=None,
+ error_messages={
+ "invalid": ("Enter an email address in the required format, like name@example.com."),
+ "required": ("Enter an email address in the required format, like name@example.com."),
+ },
+ validators=[
+ MaxLengthValidator(
+ 320,
+ message="Response must be less than 320 characters.",
+ )
+ ],
+ required=True
+ )
+
+ class Meta:
+ model = User
+ fields = ['email'] #, 'grade', 'sport']
+
+ def __init__(self, *args, **kwargs):
+ super(NewMemberForm, self).__init__(*args, **kwargs)
+ # self.fields['sport'].choices = []
+
+ def clean(self):
+ cleaned_data = super().clean()
+
+ # Lowercase the value of the 'email' field
+ email_value = cleaned_data.get("email")
+ if email_value:
+ cleaned_data["email"] = email_value.lower()
+
+ # Check for an existing user (if there isn't any, send an invite)
+ if email_value:
+ try:
+ existingUser = User.objects.get(email=email_value)
+ except existingUser.DoesNotExist:
+ raise forms.ValidationError("User with this email does not exist.")
+
+ # grade = cleaned_data.get('grade')
+ # sport = cleaned_data.get('sport')
+
+ # # Handle sport options based on grade
+ # if grade == 'Junior':
+ # self.fields['sport'].choices = [('Basketball', 'Basketball'), ('Football', 'Football')]
+ # elif grade == 'Varsity':
+ # self.fields['sport'].choices = [('Swimming', 'Swimming'), ('Tennis', 'Tennis')]
+
+ # # Ensure both sport and grade are selected and valid
+ # if not grade or not sport:
+ # raise forms.ValidationError("Both grade and sport must be selected.")
+
+ return cleaned_data
\ No newline at end of file
diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html
index 82e06c808..5cddc026f 100644
--- a/src/registrar/templates/portfolio_members.html
+++ b/src/registrar/templates/portfolio_members.html
@@ -20,7 +20,7 @@
-
Add a new member
diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html
new file mode 100644
index 000000000..97e92b560
--- /dev/null
+++ b/src/registrar/templates/portfolio_members_add_new.html
@@ -0,0 +1,117 @@
+{% extends 'portfolio_base.html' %}
+{% load static url_helpers %}
+{% load field_helpers %}
+
+{% block title %} Members | New Member {% endblock %}
+
+{% block wrapper_class %}
+ {{ block.super }} dashboard--grey-1
+{% endblock %}
+
+{% block portfolio_content %}
+{% block messages %}
+ {% include "includes/form_messages.html" %}
+{% endblock messages%}
+
+
+
+{% block new_member_header %}
+
Add a new member
+{% endblock new_member_header %}
+
+{% include "includes/required_fields.html" %}
+
+{% block form_fields %}
+
+{% endblock form_fields%}
+{% endblock portfolio_content%}
+
+
diff --git a/src/registrar/templates/portfolio_no_domains.html b/src/registrar/templates/portfolio_no_domains.html
index 75ff3a91f..bc42a0e39 100644
--- a/src/registrar/templates/portfolio_no_domains.html
+++ b/src/registrar/templates/portfolio_no_domains.html
@@ -1,4 +1,4 @@
-{% extends 'portfolio_base.html' %}
+{% extends 'portfolio_no_domains.html' %}
{% load static %}
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index 552fdb6ff..49925b2ef 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -1,12 +1,15 @@
import logging
+from django.db import IntegrityError
from django.http import Http404
-from django.shortcuts import render
+from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.contrib import messages
-from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm
+from registrar.forms import portfolio as portfolioForms
from registrar.models import Portfolio, User
+from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
+from registrar.utility.email import EmailSendingError
from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView,
PortfolioDomainsPermissionView,
@@ -42,15 +45,6 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
return render(request, "portfolio_requests.html")
-class PortfolioMembersView(PortfolioMembersPermissionView, View):
-
- template_name = "portfolio_members.html"
-
- def get(self, request):
- """Add additional context data to the template."""
- return render(request, "portfolio_members.html")
-
-
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domains.
This is a custom view which explains that to the user - and denotes who to contact.
@@ -116,7 +110,7 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
model = Portfolio
template_name = "portfolio_organization.html"
- form_class = PortfolioOrgAddressForm
+ form_class = portfolioForms.PortfolioOrgAddressForm
context_object_name = "portfolio"
def get_context_data(self, **kwargs):
@@ -179,7 +173,7 @@ class PortfolioSeniorOfficialView(PortfolioBasePermissionView, FormMixin):
model = Portfolio
template_name = "portfolio_senior_official.html"
- form_class = PortfolioSeniorOfficialForm
+ form_class = portfolioForms.PortfolioSeniorOfficialForm
context_object_name = "portfolio"
def get_object(self, queryset=None):
@@ -200,3 +194,238 @@ class PortfolioSeniorOfficialView(PortfolioBasePermissionView, FormMixin):
self.object = self.get_object()
form = self.get_form()
return self.render_to_response(self.get_context_data(form=form))
+
+
+class PortfolioMembersView(PortfolioMembersPermissionView, View):
+
+ template_name = "portfolio_members.html"
+
+ def get(self, request):
+ """Add additional context data to the template."""
+ return render(request, "portfolio_members.html")
+
+
+class NewMemberView(PortfolioMembersPermissionView, FormMixin):
+
+ # template_name = "portfolio_members_add_new.html"
+ # form = portfolioForms.NewMemberForm #[forms.NewMemberToggleForm, forms.OtherContactsFormSet, forms.NoOtherContactsForm]
+
+ model = UserPortfolioPermission
+ template_name = "portfolio_members_add_new.html"
+ form_class = portfolioForms.NewMemberForm
+ context_object_name = "userPortfolioPermission"
+
+ def get_object(self, queryset=None):
+ """Get the portfolio object based on the session."""
+ portfolio = self.request.session.get("portfolio")
+ if portfolio is None:
+ raise Http404("No organization found for this user")
+ return portfolio
+
+ def get_form_kwargs(self):
+ """Include the instance in the form kwargs."""
+ kwargs = super().get_form_kwargs()
+ kwargs["instance"] = self.get_object()
+ return kwargs
+
+ def get(self, request, *args, **kwargs):
+ """Handle GET requests to display the form."""
+ self.object = self.get_object()
+ form = self.get_form()
+ return self.render_to_response(self.get_context_data(form=form))
+
+ ##########################################
+ # TODO: future ticket
+ # (save/invite new member)
+ ##########################################
+
+ # def _send_domain_invitation_email(self, email: str, requestor: User, add_success=True):
+ # """Performs the sending of the member invitation email
+ # email: string- email to send to
+ # add_success: bool- default True indicates:
+ # adding a success message to the view if the email sending succeeds
+
+ # raises EmailSendingError
+ # """
+
+ # # Set a default email address to send to for staff
+ # requestor_email = settings.DEFAULT_FROM_EMAIL
+
+ # # Check if the email requestor has a valid email address
+ # if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "":
+ # requestor_email = requestor.email
+ # elif not requestor.is_staff:
+ # messages.error(self.request, "Can't send invitation email. No email is associated with your account.")
+ # logger.error(
+ # f"Can't send email to '{email}' on domain '{self.object}'."
+ # f"No email exists for the requestor '{requestor.username}'.",
+ # exc_info=True,
+ # )
+ # return None
+
+ # # Check to see if an invite has already been sent
+ # try:
+ # invite = MemberInvitation.objects.get(email=email, domain=self.object)
+ # # check if the invite has already been accepted
+ # if invite.status == MemberInvitation.MemberInvitationStatus.RETRIEVED:
+ # add_success = False
+ # messages.warning(
+ # self.request,
+ # f"{email} is already a manager for this domain.",
+ # )
+ # else:
+ # add_success = False
+ # # else if it has been sent but not accepted
+ # messages.warning(self.request, f"{email} has already been invited to this domain")
+ # except Exception:
+ # logger.error("An error occured")
+
+ # try:
+ # send_templated_email(
+ # "emails/member_invitation.txt",
+ # "emails/member_invitation_subject.txt",
+ # to_address=email,
+ # context={
+ # "portfolio": self.object,
+ # "requestor_email": requestor_email,
+ # },
+ # )
+ # except EmailSendingError as exc:
+ # logger.warn(
+ # "Could not sent email invitation to %s for domain %s",
+ # email,
+ # self.object,
+ # exc_info=True,
+ # )
+ # raise EmailSendingError("Could not send email invitation.") from exc
+ # else:
+ # if add_success:
+ # messages.success(self.request, f"{email} has been invited to this domain.")
+
+ # def _make_invitation(self, email_address: str, requestor: User):
+ # """Make a Member invitation for this email and redirect with a message."""
+ # try:
+ # self._send_member_invitation_email(email=email_address, requestor=requestor)
+ # except EmailSendingError:
+ # messages.warning(self.request, "Could not send email invitation.")
+ # else:
+ # # (NOTE: only create a MemberInvitation if the e-mail sends correctly)
+ # MemberInvitation.objects.get_or_create(email=email_address, domain=self.object)
+ # return redirect(self.get_success_url())
+
+ # def form_valid(self, form):
+
+ # """Add the specified user as a member
+ # for this portfolio.
+ # Throws EmailSendingError."""
+ # requested_email = form.cleaned_data["email"]
+ # requestor = self.request.user
+ # # look up a user with that email
+ # try:
+ # requested_user = User.objects.get(email=requested_email)
+ # except User.DoesNotExist:
+ # # no matching user, go make an invitation
+ # return self._make_invitation(requested_email, requestor)
+ # else:
+ # # if user already exists then just send an email
+ # try:
+ # self._send_member_invitation_email(requested_email, requestor, add_success=False)
+ # except EmailSendingError:
+ # logger.warn(
+ # "Could not send email invitation (EmailSendingError)",
+ # self.object,
+ # exc_info=True,
+ # )
+ # messages.warning(self.request, "Could not send email invitation.")
+ # except Exception:
+ # logger.warn(
+ # "Could not send email invitation (Other Exception)",
+ # self.object,
+ # exc_info=True,
+ # )
+ # messages.warning(self.request, "Could not send email invitation.")
+
+ # try:
+ # UserPortfolioPermission.objects.create(
+ # user=requested_user,
+ # portfolio=self.object,
+ # role=UserDomainRole.Roles.MANAGER,
+ # )
+ # except IntegrityError:
+ # messages.warning(self.request, f"{requested_email} is already a member of this portfolio")
+ # else:
+ # messages.success(self.request, f"Added user {requested_email}.")
+ # return redirect(self.get_success_url())
+
+
+
+
+
+
+
+
+
+
+
+
+
+# class NewMemberView(PortfolioMembersPermissionView, FormMixin):
+# form = portfolioForms.NewMemberForm
+# template_name = 'portfolio_members_add_new.html' # Assuming you have a template file for the form
+
+# # model = UserPortfolioPermission
+# # template_name = "portfolio_members_add_new.html"
+# # form_class = portfolioForms.NewMemberForm
+# # context_object_name = "userPortfolioPermission"
+
+# def get_success_url(self):
+# return reverse('success') # Redirect after successful submission
+
+# def get_context_data(self, **kwargs):
+# """Add additional context data to the template."""
+# #TODO: Add permissions to context
+# context = super().get_context_data(**kwargs)
+# portfolio = self.request.session.get("portfolio")
+# context["has_invite_members_permission"] = self.request.user.has_edit_members_portfolio_permission(portfolio)
+# return context
+
+# def form_valid(self, form):
+# # Get the cleaned data from the form
+# cleaned_data = form.cleaned_data
+# email = cleaned_data.get('email')
+# # grade = cleaned_data.get('grade')
+# # sport = cleaned_data.get('sport')
+
+# ##########################################
+# # TODO: future ticket
+# # (validate and save/invite new member here)
+# ##########################################
+
+# # Lookup member by email
+# # member = get_object_or_404(User, email=email)
+
+# # Check existing portfolio permissions
+# # TODO: future ticket -- check for existing portfolio permissions, multipe portfolio flags, etc.
+# # school = self.get_context_data()['school']
+
+# # Update student school information
+# # student.school = school
+# # student.save()
+
+# # Create or update the SportEnrollment for this student
+# # SportEnrollment.objects.create(
+# # student=student,
+# # grade=grade,
+# # sport=sport
+# # )
+
+# return super().form_valid(form)
+
+# def form_invalid(self, form):
+# # If the form is invalid, show errors
+# return self.render_to_response(self.get_context_data(form=form))
+
+
+# def get(self, request):
+# return render(request, "portfolio_members_add_new.html")
+
diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py
index 414e58275..6175b6104 100644
--- a/src/registrar/views/utility/permission_views.py
+++ b/src/registrar/views/utility/permission_views.py
@@ -253,7 +253,7 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P
class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC):
- """Abstract base view for portfolio domain request views that enforces permissions.
+ """Abstract base view for portfolio members views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.