Skip to content

Commit

Permalink
Merge pull request #2920 from cisagov/dk/2789-member-page
Browse files Browse the repository at this point in the history
#2789: Portfolio member/invited member page
  • Loading branch information
dave-kennedy-ecs authored Oct 16, 2024
2 parents baa3f57 + 928fe01 commit 9fd41c8
Show file tree
Hide file tree
Showing 27 changed files with 1,169 additions and 133 deletions.
12 changes: 6 additions & 6 deletions src/registrar/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,11 @@ class Meta:
model = models.PortfolioInvitation
fields = "__all__"
widgets = {
"portfolio_roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
"roles": FilteredSelectMultipleArrayWidget(
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions",
"additional_permissions": FilteredSelectMultipleArrayWidget(
"additional_permissions",
is_stacked=False,
choices=UserPortfolioPermissionChoices.choices,
),
Expand Down Expand Up @@ -1409,8 +1409,8 @@ class Meta:
list_display = [
"email",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
"roles",
"additional_permissions",
"status",
]

Expand Down
45 changes: 36 additions & 9 deletions src/registrar/assets/js/get-gov.js
Original file line number Diff line number Diff line change
Expand Up @@ -1880,19 +1880,17 @@ class MembersTable extends LoadTableBase {
* @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc}
* @param {*} scroll - control for the scrollToElement functionality
* @param {*} status - control for the status filter
* @param {*} searchTerm - the search term
* @param {*} portfolio - the portfolio id
*/
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {

// --------- SEARCH
let searchParams = new URLSearchParams(
{
"page": page,
"sort_by": sortBy,
"order": order,
"status": status,
"search_term": searchTerm
}
);
Expand Down Expand Up @@ -1928,11 +1926,40 @@ class MembersTable extends LoadTableBase {
const memberList = document.querySelector('.members__table tbody');
memberList.innerHTML = '';

const invited = 'Invited';

data.members.forEach(member => {
// const actionUrl = domain.action_url;
const member_name = member.name;
const member_email = member.email;
const last_active = member.last_active;
const member_display = member.member_display;
const options = { year: 'numeric', month: 'short', day: 'numeric' };

// Handle last_active values
let last_active = member.last_active;
let last_active_formatted = '';
let last_active_sort_value = '';

// Handle 'Invited' or null/empty values differently from valid dates
if (last_active && last_active !== invited) {
try {
// Try to parse the last_active as a valid date
last_active = new Date(last_active);
if (!isNaN(last_active)) {
last_active_formatted = last_active.toLocaleDateString('en-US', options);
last_active_sort_value = last_active.getTime(); // For sorting purposes
} else {
last_active_formatted='Invalid date'
}
} catch (e) {
console.error(`Error parsing date: ${last_active}. Error: ${e}`);
last_active_formatted='Invalid date'
}
} else {
// Handle 'Invited' or null
last_active = invited;
last_active_formatted = invited;
last_active_sort_value = invited; // Keep 'Invited' as a sortable string
}

const action_url = member.action_url;
const action_label = member.action_label;
const svg_icon = member.svg_icon;
Expand All @@ -1945,10 +1972,10 @@ class MembersTable extends LoadTableBase {

row.innerHTML = `
<th scope="row" role="rowheader" data-label="member email">
${member_email ? member_email : member_name} ${admin_tagHTML}
${member_display} ${admin_tagHTML}
</th>
<td data-sort-value="${last_active}" data-label="last_active">
${last_active}
<td data-sort-value="${last_active_sort_value}" data-label="last_active">
${last_active_formatted}
</td>
<td>
<a href="${action_url}">
Expand Down
3 changes: 2 additions & 1 deletion src/registrar/assets/sass/_theme/_buttons.scss
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ a .usa-icon,
// Note: Can be simplified by adding text-secondary to delete anchors in tables
button.text-secondary,
button.text-secondary:hover,
.dotgov-table a.text-secondary {
a.text-secondary,
a.text-secondary:hover {
color: $theme-color-error;
}
20 changes: 20 additions & 0 deletions src/registrar/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,26 @@
views.PortfolioMembersView.as_view(),
name="members",
),
path(
"member/<int:pk>",
views.PortfolioMemberView.as_view(),
name="member",
),
path(
"member/<int:pk>/permissions",
views.PortfolioMemberEditView.as_view(),
name="member-permissions",
),
path(
"invitedmember/<int:pk>",
views.PortfolioInvitedMemberView.as_view(),
name="invitedmember",
),
path(
"invitedmember/<int:pk>/permissions",
views.PortfolioInvitedMemberEditView.as_view(),
name="invitedmember-permissions",
),
# path(
# "no-organization-members/",
# views.PortfolioNoMembersView.as_view(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from registrar.models import User
from registrar.models.portfolio import Portfolio
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices

fake = Faker()
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -58,6 +58,7 @@ def load(cls):
user=user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
)
user_portfolio_permissions_to_create.append(user_portfolio_permission)
else:
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 @@ -13,4 +13,5 @@
)
from .portfolio import (
PortfolioOrgAddressForm,
PortfolioMemberForm,
)
63 changes: 62 additions & 1 deletion src/registrar/forms/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
from django import forms
from django.core.validators import RegexValidator

from ..models import DomainInformation, Portfolio, SeniorOfficial
from registrar.models import (
PortfolioInvitation,
UserPortfolioPermission,
DomainInformation,
Portfolio,
SeniorOfficial,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -99,3 +106,57 @@ def clean(self):
cleaned_data = super().clean()
cleaned_data.pop("full_name", None)
return cleaned_data


class PortfolioMemberForm(forms.ModelForm):
"""
Form for updating a portfolio member.
"""

roles = forms.MultipleChoiceField(
choices=UserPortfolioRoleChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Roles",
)

additional_permissions = forms.MultipleChoiceField(
choices=UserPortfolioPermissionChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Additional Permissions",
)

class Meta:
model = UserPortfolioPermission
fields = [
"roles",
"additional_permissions",
]


class PortfolioInvitedMemberForm(forms.ModelForm):
"""
Form for updating a portfolio invited member.
"""

roles = forms.MultipleChoiceField(
choices=UserPortfolioRoleChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Roles",
)

additional_permissions = forms.MultipleChoiceField(
choices=UserPortfolioPermissionChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Additional Permissions",
)

class Meta:
model = PortfolioInvitation
fields = [
"roles",
"additional_permissions",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.10 on 2024-10-11 19:58

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("registrar", "0133_domainrequest_rejection_reason_email_and_more"),
]

operations = [
migrations.RenameField(
model_name="portfolioinvitation",
old_name="portfolio_additional_permissions",
new_name="additional_permissions",
),
migrations.RenameField(
model_name="portfolioinvitation",
old_name="portfolio_roles",
new_name="roles",
),
]
38 changes: 32 additions & 6 deletions src/registrar/models/portfolio_invitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.contrib.auth import get_user_model
from django.db import models
from django_fsm import FSMField, transition
from registrar.models.domain_invitation import DomainInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
from .utility.time_stamped_model import TimeStampedModel
Expand Down Expand Up @@ -38,7 +39,7 @@ class PortfolioInvitationStatus(models.TextChoices):
related_name="portfolios",
)

portfolio_roles = ArrayField(
roles = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioRoleChoices.choices,
Expand All @@ -48,7 +49,7 @@ class PortfolioInvitationStatus(models.TextChoices):
help_text="Select one or more roles.",
)

portfolio_additional_permissions = ArrayField(
additional_permissions = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioPermissionChoices.choices,
Expand All @@ -67,6 +68,31 @@ class PortfolioInvitationStatus(models.TextChoices):
def __str__(self):
return f"Invitation for {self.email} on {self.portfolio} is {self.status}"

def get_managed_domains_count(self):
"""Return the count of domain invitations managed by the invited user for this portfolio."""
# Filter the UserDomainRole model to get domains where the user has a manager role
managed_domains = DomainInvitation.objects.filter(
email=self.email, domain__domain_info__portfolio=self.portfolio
).count()
return managed_domains

def get_portfolio_permissions(self):
"""
Retrieve the permissions for the user's portfolio roles from the invite.
This is similar logic to _get_portfolio_permissions in user_portfolio_permission
"""
# Use a set to avoid duplicate permissions
portfolio_permissions = set()

if self.roles:
for role in self.roles:
portfolio_permissions.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))

if self.additional_permissions:
portfolio_permissions.update(self.additional_permissions)

return list(portfolio_permissions)

@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission.
Expand All @@ -88,8 +114,8 @@ def retrieve(self):
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=self.portfolio, user=user
)
if self.portfolio_roles and len(self.portfolio_roles) > 0:
user_portfolio_permission.roles = self.portfolio_roles
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions
if self.roles and len(self.roles) > 0:
user_portfolio_permission.roles = self.roles
if self.additional_permissions and len(self.additional_permissions) > 0:
user_portfolio_permission.additional_permissions = self.additional_permissions
user_portfolio_permission.save()
9 changes: 9 additions & 0 deletions src/registrar/models/user_portfolio_permission.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.db import models
from django.forms import ValidationError
from registrar.models.user_domain_role import UserDomainRole
from registrar.utility.waffle import flag_is_active_for_user
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .utility.time_stamped_model import TimeStampedModel
Expand Down Expand Up @@ -79,6 +80,14 @@ def get_readable_roles(self):
)
return readable_roles

def get_managed_domains_count(self):
"""Return the count of domains managed by the user for this portfolio."""
# Filter the UserDomainRole model to get domains where the user has a manager role
managed_domains = UserDomainRole.objects.filter(
user=self.user, role=UserDomainRole.Roles.MANAGER, domain__domain_info__portfolio=self.portfolio
).count()
return managed_domains

def _get_portfolio_permissions(self):
"""
Retrieve the permissions for the user's portfolio roles.
Expand Down
12 changes: 6 additions & 6 deletions src/registrar/templates/includes/header_extended.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@
</li>
{% endif %}

{% if has_organization_members_flag and has_view_members_portfolio_permission %}
<li class="usa-nav__primary-item">
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
Members
</a>
</li>
{% if has_organization_members_flag %}
<li class="usa-nav__primary-item">
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
Members
</a>
</li>
{% endif %}

<li class="usa-nav__primary-item">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<h4 class="margin-bottom-0 text-primary">Assigned domains</h4>
{% if domain_count > 0 %}
<p class="margin-top-0">{{domain_count}}</p>
{% else %}
<p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Manage".{% endif %}</p>
{% endif %}
Loading

0 comments on commit 9fd41c8

Please sign in to comment.