Merge branch 'rjm/2349-portfolio-ui' into za/2354-handle-portfolio-edit-mode

This commit is contained in:
zandercymatics 2024-07-22 08:45:52 -06:00
commit cfd6248fc3
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
33 changed files with 534 additions and 418 deletions

View file

@ -429,6 +429,10 @@ class ViewsTest(TestCase):
# Create a mock request # Create a mock request
request = self.factory.get("/some-url") request = self.factory.get("/some-url")
request.session = {"acr_value": ""} request.session = {"acr_value": ""}
# Mock user and its attributes
mock_user = MagicMock()
mock_user.is_authenticated = True
request.user = mock_user
# Ensure that the CLIENT instance used in login_callback is the mock # Ensure that the CLIENT instance used in login_callback is the mock
# patch _requires_step_up_auth to return False # patch _requires_step_up_auth to return False
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(

View file

@ -1140,6 +1140,7 @@ document.addEventListener('DOMContentLoaded', function() {
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]'); const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
const statusIndicator = document.querySelector('.domain__filter-indicator'); const statusIndicator = document.querySelector('.domain__filter-indicator');
const statusToggle = document.querySelector('.usa-button--filter'); const statusToggle = document.querySelector('.usa-button--filter');
const noPortfolioFlag = document.getElementById('no-portfolio-js-flag');
/** /**
* Loads rows in the domains list, as well as updates pagination around the domains list * Loads rows in the domains list, as well as updates pagination around the domains list
@ -1173,8 +1174,20 @@ document.addEventListener('DOMContentLoaded', function() {
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url; const actionUrl = domain.action_url;
const suborganization = domain.suborganization ? domain.suborganization : '';
const row = document.createElement('tr'); const row = document.createElement('tr');
let markupForSuborganizationRow = '';
if (!noPortfolioFlag) {
markupForSuborganizationRow = `
<td>
<span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span>
</td>
`
}
row.innerHTML = ` row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name"> <th scope="row" role="rowheader" data-label="Domain name">
${domain.name} ${domain.name}
@ -1195,6 +1208,7 @@ document.addEventListener('DOMContentLoaded', function() {
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use> <use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
</svg> </svg>
</td> </td>
${markupForSuborganizationRow}
<td> <td>
<a href="${actionUrl}"> <a href="${actionUrl}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">

View file

@ -29,52 +29,14 @@ body {
#wrapper.dashboard { #wrapper.dashboard {
background-color: color('primary-lightest'); background-color: color('primary-lightest');
padding-top: units(5); padding-top: units(5)!important;
} }
.usa-logo { #wrapper.dashboard--portfolio {
@include at-media(desktop) { background-color: color('gray-1');
margin-top: units(2); padding-top: units(4)!important;
}
} }
.usa-logo__text {
@include typeset('sans', 'xl', 2);
color: color('primary-darker');
}
.usa-nav__primary {
margin-top:units(1);
}
.usa-nav__primary-username {
display: inline-block;
padding: units(1) units(2);
max-width: 208px;
overflow: hidden;
text-overflow: ellipsis;
@include at-media(desktop) {
padding: units(2);
max-width: 500px;
}
}
@include at-media(desktop) {
.usa-nav__primary-item:not(:first-child) {
position: relative;
}
.usa-nav__primary-item:not(:first-child)::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 0; /* No width since it's a border */
height: 40%;
border-left: solid 1px color('base-light');
transform: translateY(-50%);
}
}
.section--outlined { .section--outlined {
background-color: color('white'); background-color: color('white');
@ -136,10 +98,6 @@ footer {
color: color('primary'); color: color('primary');
} }
.usa-identifier__logo {
height: units(7);
}
abbr[title] { abbr[title] {
// workaround for underlining abbr element // workaround for underlining abbr element
border-bottom: none; border-bottom: none;
@ -179,47 +137,35 @@ abbr[title] {
cursor: pointer; cursor: pointer;
} }
.input-with-edit-button {
svg.usa-icon {
width: 1.5em !important;
height: 1.5em !important;
color: #{$dhs-green};
position: absolute;
}
&.input-with-edit-button__error {
svg.usa-icon {
color: #{$dhs-red};
}
div.input-with-edit-button__readonly-field {
color: #{$dhs-red};
}
}
}
// We need to deviate from some default USWDS styles here
// in this particular case, so we have to override this.
.usa-form .usa-button.readonly-edit-button {
margin-top: 0px !important;
padding-top: 0px !important;
svg {
width: 1.25em !important;
height: 1.25em !important;
}
}
// Define some styles for the .gov header/logo
.usa-logo button {
color: #{$dhs-dark-gray-85};
font-weight: 700;
font-family: family('sans');
font-size: 1.6rem;
line-height: 1.1;
}
.usa-logo button.usa-button--unstyled.disabled-button:hover{
color: #{$dhs-dark-gray-85};
}
.padding--8-8-9 { .padding--8-8-9 {
padding: 8px 8px 9px !important; padding: 8px 8px 9px !important;
} }
.ellipsis {
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ellipsis--23 {
max-width: 23ch;
}
.ellipsis--30 {
max-width: 30ch;
}
.ellipsis--50 {
max-width: 50ch;
}
.vertical-align-middle {
vertical-align: middle;
}
@include at-media(desktop) {
.ellipsis--desktop-50 {
max-width: 50ch;
}
}

View file

@ -162,6 +162,34 @@ a.usa-button--unstyled:visited {
} }
} }
.input-with-edit-button {
svg.usa-icon {
width: 1.5em !important;
height: 1.5em !important;
color: #{$dhs-green};
position: absolute;
}
&.input-with-edit-button__error {
svg.usa-icon {
color: #{$dhs-red};
}
div.readonly-field {
color: #{$dhs-red};
}
}
}
// We need to deviate from some default USWDS styles here
// in this particular case, so we have to override this.
.usa-form .usa-button.readonly-edit-button {
margin-top: 0px !important;
padding-top: 0px !important;
svg {
width: 1.25em !important;
height: 1.25em !important;
}
}
.usa-button--filter { .usa-button--filter {
width: auto; width: auto;
// For mobile stacking // For mobile stacking

View file

@ -0,0 +1,121 @@
@use "uswds-core" as *;
@use "cisa_colors" as *;
// Define some styles for the .gov header/logo
.usa-logo button {
color: #{$dhs-dark-gray-85};
font-weight: 700;
font-family: family('sans');
font-size: 1.6rem;
line-height: 1.1;
}
.usa-logo button:hover{
color: #{$dhs-dark-gray-85};
}
.usa-header {
.usa-logo {
@include at-media(desktop) {
margin-top: units(2);
}
}
.usa-logo__text {
@include typeset('sans', 'xl', 2);
}
.usa-nav__username {
max-width: 208px;
min-height: units(2);
@include at-media(desktop) {
max-width: 500px;
}
}
.padding-y-0 {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
}
.usa-header--basic {
.usa-logo__text {
color: color('primary-darker');
}
.usa-nav__username {
padding: units(1) units(2);
@include at-media(desktop) {
padding: units(2);
}
}
.usa-nav__primary {
margin-top:units(1);
}
@include at-media(desktop) {
.usa-nav__primary-item:not(:first-child) {
position: relative;
}
.usa-nav__primary-item:not(:first-child)::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 0; /* No width since it's a border */
height: 40%;
border-left: solid 1px color('base-light');
transform: translateY(-50%);
}
}
}
.usa-header--extended {
@include at-media(desktop) {
background-color: color('primary-darker');
border-top: solid 1px color('base-light');
border-bottom: solid 1px color('base-lighter');
.usa-logo__text a,
.usa-logo__text button,
.usa-logo__text button:hover {
color: color('white');
}
.usa-nav {
background-color: color('primary-lightest');
}
.usa-nav__primary-item:last-child {
margin-left: auto;
.usa-nav-link {
margin-right: units(-2);
}
}
.usa-nav__primary {
.usa-nav-link,
.usa-nav-link:hover,
.usa-nav-link:active {
color: color('primary');
font-weight: font-weight('normal');
font-size: 16px;
}
.usa-current,
.usa-current:hover,
.usa-current:active {
font-weight: font-weight('bold');
}
}
.usa-nav__secondary {
// I don't know why USWDS has this at 2 rem, which puts it out of alignment
right: 3rem;
color: color('white');
bottom: 4.3rem;
.usa-nav-link,
.usa-nav-link:hover,
.usa-nav-link:active {
font-weight: font-weight('bold');
color: color('primary-lighter');
font-size: 16px;
}
}
> .usa-navbar {
// This is a dangerous override to USWDS, necessary because we have a tooltip on the logo
overflow: visible;
}
}
}

View file

@ -0,0 +1,9 @@
@use "uswds-core" as *;
.usa-banner {
background-color: color('primary-darker');
}
.usa-identifier__logo {
height: units(7);
}

View file

@ -34,22 +34,6 @@
pointer-events: none; pointer-events: none;
} }
} }
// Ticket #1510
// @include at-media('desktop') {
// th:first-child {
// width: 220px;
// }
// th:nth-child(2) {
// width: 175px;
// }
// th:nth-child(3) {
// width: 130px;
// }
// th:nth-child(5) {
// width: 130px;
// }
// }
} }
.dotgov-table { .dotgov-table {
@ -96,46 +80,3 @@
} }
} }
} }
@media (min-width: 1040px){
.domain-requests__table {
th:nth-of-type(1) {
width: 200px;
}
th:nth-of-type(2) {
width: 158px;
}
th:nth-of-type(3) {
width: 120px;
}
th:nth-of-type(4) {
width: 95px;
}
th:nth-of-type(5) {
width: 85px;
}
}
}
@media (min-width: 1040px){
.domains__table {
th:nth-of-type(1) {
width: 200px;
}
th:nth-of-type(2) {
width: 158px;
}
th:nth-of-type(3) {
width: 215px;
}
th:nth-of-type(4) {
width: 95px;
}
}
}

View file

@ -21,6 +21,8 @@
@forward "alerts"; @forward "alerts";
@forward "tables"; @forward "tables";
@forward "sidenav"; @forward "sidenav";
@forward "identifier";
@forward "header";
@forward "register-form"; @forward "register-form";
/*-------------------------------------------------- /*--------------------------------------------------

View file

@ -240,6 +240,10 @@ TEMPLATES = [
"registrar.context_processors.canonical_path", "registrar.context_processors.canonical_path",
"registrar.context_processors.is_demo_site", "registrar.context_processors.is_demo_site",
"registrar.context_processors.is_production", "registrar.context_processors.is_production",
"registrar.context_processors.org_user_status",
"registrar.context_processors.add_portfolio_to_context",
"registrar.context_processors.add_path_to_context",
"registrar.context_processors.add_has_profile_feature_flag_to_context",
], ],
}, },
}, },

View file

@ -1,4 +1,5 @@
from django.conf import settings from django.conf import settings
from waffle.decorators import flag_is_active
def language_code(request): def language_code(request):
@ -36,3 +37,26 @@ def is_demo_site(request):
def is_production(request): def is_production(request):
"""Add a boolean if this is our production site.""" """Add a boolean if this is our production site."""
return {"IS_PRODUCTION": settings.IS_PRODUCTION} return {"IS_PRODUCTION": settings.IS_PRODUCTION}
def org_user_status(request):
if request.user.is_authenticated:
is_org_user = request.user.is_org_user(request)
else:
is_org_user = False
return {
"is_org_user": is_org_user,
}
def add_portfolio_to_context(request):
return {"portfolio": getattr(request, "portfolio", None)}
def add_path_to_context(request):
return {"path": getattr(request, "path", None)}
def add_has_profile_feature_flag_to_context(request):
return {"has_profile_feature_flag": flag_is_active(request, "profile_feature")}

View file

@ -4,6 +4,7 @@ from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from registrar.models.portfolio import Portfolio
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from .domain_invitation import DomainInvitation from .domain_invitation import DomainInvitation
@ -11,6 +12,7 @@ from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff from .verified_by_staff import VerifiedByStaff
from .domain import Domain from .domain import Domain
from .domain_request import DomainRequest from .domain_request import DomainRequest
from waffle.decorators import flag_is_active
from phonenumber_field.modelfields import PhoneNumberField # type: ignore from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -288,3 +290,9 @@ class User(AbstractUser):
""" """
self.check_domain_invitations_on_login() self.check_domain_invitations_on_login()
def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature")
user_portfolios_exist = Portfolio.objects.filter(creator=self).exists()
return has_organization_feature_flag and user_portfolios_exist

View file

@ -141,14 +141,16 @@ class CheckPortfolioMiddleware:
def process_view(self, request, view_func, view_args, view_kwargs): def process_view(self, request, view_func, view_args, view_kwargs):
current_path = request.path current_path = request.path
has_organization_feature_flag = flag_is_active(request, "organization_feature") if request.user.is_authenticated and request.user.is_org_user(request):
user_portfolios = Portfolio.objects.filter(creator=request.user)
first_portfolio = user_portfolios.first()
if first_portfolio:
# Add the portfolio to the request object
request.portfolio = first_portfolio
if current_path == self.home: if current_path == self.home:
if has_organization_feature_flag:
if request.user.is_authenticated:
user_portfolios = Portfolio.objects.filter(creator=request.user)
if user_portfolios.exists():
first_portfolio = user_portfolios.first()
home_with_portfolio = reverse("portfolio-domains", kwargs={"portfolio_id": first_portfolio.id}) home_with_portfolio = reverse("portfolio-domains", kwargs={"portfolio_id": first_portfolio.id})
return HttpResponseRedirect(home_with_portfolio) return HttpResponseRedirect(home_with_portfolio)
return None return None

View file

@ -133,48 +133,10 @@
</section> </section>
{% block usa_overlay %}<div class="usa-overlay"></div>{% endblock %} <div class="usa-overlay"></div>
{% block banner %} {% block header %}
<header class="usa-header usa-header--basic"> {% include "includes/header_selector.html" with logo_clickable=True %}
<div class="usa-nav-container"> {% endblock header %}
<div class="usa-navbar">
{% block logo %}
{% include "includes/gov_extended_logo.html" with logo_clickable=True %}
{% endblock %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation">
<button type="button" class="usa-nav__close">
<img src="/public/img/usa-icons/close.svg" role="img" alt="Close" />
</button>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if user.is_authenticated %}
<span class="usa-nav__primary-username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__primary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if request.path == user_profile_url or request.path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
<span class="text-primary">Your profile</span>
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="{% url 'logout' %}"><span class="text-primary">Sign out</span></a>
{% else %}
<a href="{% url 'login' %}"><span>Sign in</span></a>
{% endif %}
</li>
</ul>
</nav>
{% block usa_nav_secondary %}{% endblock %}
{% endblock %}
</div>
</header>
{% endblock banner %}
{% block wrapper %} {% block wrapper %}
<div id="wrapper"> <div id="wrapper">

View file

@ -4,8 +4,8 @@
{% block title %} Finish setting up your profile | {% endblock %} {% block title %} Finish setting up your profile | {% endblock %}
{# Disable the redirect #} {# Disable the redirect #}
{% block logo %} {% block header %}
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %} {% include "includes/header_selector.html" with logo_clickable=user_finished_setup %}
{% endblock %} {% endblock %}
{# Add the new form #} {# Add the new form #}

View file

@ -10,11 +10,11 @@
{# the entire logged in page goes here #} {# the entire logged in page goes here #}
{% block homepage_content %} {% block homepage_content %}
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1"> <div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
{% block messages %} {% block messages %}
{% include "includes/form_messages.html" %} {% include "includes/form_messages.html" %}
{% endblock %} {% endblock %}
<h1>Manage your domains</h1> <h1>Manage your domains</h1>
{% comment %} {% comment %}
@ -32,26 +32,8 @@
{% include "includes/domains_table.html" %} {% include "includes/domains_table.html" %}
{% include "includes/domain_requests_table.html" %} {% include "includes/domain_requests_table.html" %}
{# Note: Reimplement this after MVP #}
<!--
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
<h2>Archived domains</h2>
<p>You don't have any archived domains</p>
</section>
-->
<!-- Note: Uncomment below when this is being implemented post-MVP -->
<!-- <section class="tablet:grid-col-11 desktop:grid-col-10">
<h2 class="padding-top-1 mobile-lg:padding-top-3"> Export domains</h2>
<p>Download a list of your domains and their statuses as a csv file.</p>
<a href="{% url 'todo' %}" class="usa-button usa-button--outline">
Export domains as csv
</a>
</section>
-->
{% endblock %}
</div> </div>
{% endblock %}
{% else %} {# not user.is_authenticated #} {% else %} {# not user.is_authenticated #}
{# the entire logged out page goes here #} {# the entire logged out page goes here #}

View file

@ -2,6 +2,7 @@
<section class="section--outlined domain-requests" id="domain-requests"> <section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row"> <div class="grid-row">
<!-- Use portfolio_base_permission when merging into 2366 and then delete this comment -->
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2> <h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
@ -12,6 +13,9 @@
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button"> <button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset Reset
</button> </button>
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label> <label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>

View file

@ -2,16 +2,21 @@
<section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains"> <section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
<div class="grid-row"> <div class="grid-row">
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domains-header" class="flex-6">Domains</h2> <h2 id="domains-header" class="flex-6">Domains</h2>
</div> </div>
<span class="display-none" id="no-portfolio-js-flag"></span>
{% endif %} {% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domains search component" class="flex-6 margin-y-2"> <section aria-label="Domains search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-search display-none" type="button"> <button class="usa-button usa-button--unstyled margin-right-3 domains__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset Reset
</button> </button>
<label class="usa-sr-only" for="domains__search-field">Search by domain name</label> <label class="usa-sr-only" for="domains__search-field">Search by domain name</label>
@ -33,9 +38,10 @@
</section> </section>
</div> </div>
</div> </div>
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio %} {% if portfolio %}
<div class="display-flex flex-align-center margin-top-1"> <div class="display-flex flex-align-center margin-top-1">
<span class="margin-right-2 margin-top-neg-1 text-base-darker">Filter by</span> <span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2"> <div class="usa-accordion usa-accordion--select margin-right-2">
<div class="usa-accordion__heading"> <div class="usa-accordion__heading">
<button <button
@ -136,6 +142,10 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th> <th data-sortable="name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th> <th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</th> <th data-sortable="state_display" scope="col" role="columnheader">Status</th>
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio %}
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
{% endif %}
<th <th
scope="col" scope="col"
role="columnheader" role="columnheader"

View file

@ -0,0 +1,39 @@
{% load static %}
<header class="usa-header usa-header--basic">
<div class="usa-nav-container">
<div class="usa-navbar">
{% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation">
<button type="button" class="usa-nav__close">
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if user.is_authenticated %}
<span class="usa-nav__username ellipsis">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__primary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if request.path == user_profile_url or request.path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
<span class="text-primary">Your profile</span>
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="{% url 'logout' %}"><span class="text-primary">Sign out</span></a>
{% else %}
<a href="{% url 'login' %}"><span>Sign in</span></a>
{% endif %}
</li>
</ul>
</nav>
{% block usa_nav_secondary %}{% endblock %}
{% endblock %}
</div>
</header>

View file

@ -0,0 +1,72 @@
{% load static %}
<header class="usa-header usa-header--extended">
<div class="usa-navbar">
{% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation">
<div class="usa-nav__inner">
<button type="button" class="usa-nav__close">
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% url 'portfolio-domains' portfolio.id as url %}
<a href="{{ url }}" class="usa-nav-link{% if request.path == url %} usa-current{% endif %}">
Domains
</a>
</li>
<li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
Domain groups
</a>
</li>
<li class="usa-nav__primary-item">
{% url 'portfolio-domain-requests' portfolio.id as url %}
<a href="{{ url }}" class="usa-nav-link{% if request.path == url %} usa-current{% endif %}">
Domain requests
</a>
</li>
<li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
Members
</a>
</li>
<li class="usa-nav__primary-item">
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
<a href="#" class="usa-nav-link padding-y-0">
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
{{ portfolio.organization_name }}
</span>
</a>
</li>
</ul>
<div class="usa-nav__secondary">
<ul class="usa-nav__secondary-links">
<li class="usa-nav__secondary-item">
{% if user.is_authenticated %}
<span class="ellipsis usa-nav__username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__secondary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if path == user_profile_url or path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
Your profile
</a>
</li>
{% endif %}
<li class="usa-nav__secondary-item">
<a class="usa-nav-link" href="{% url 'logout' %}">Sign out</a>
{% else %}
<a class="usa-nav-link" href="{% url 'login' %}">Sign in</a>
{% endif %}
</li>
</ul>
</div>
</div>
</nav>
{% endblock %}
</header>

View file

@ -0,0 +1,5 @@
{% if not is_org_user %}
{% include "includes/header_basic.html" with logo_clickable=logo_clickable %}
{% else %}
{% include "includes/header_extended.html" with logo_clickable=logo_clickable %}
{% endif %}

View file

@ -1,24 +0,0 @@
{% extends 'home.html' %}
{% load static %}
{% block homepage_content %}
<div class="tablet:grid-col-12">
<div class="grid-row grid-gap">
<div class="tablet:grid-col-3">
{% include "portfolio_sidebar.html" with portfolio=portfolio %}
</div>
<div class="tablet:grid-col-9">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
{# Note: Reimplement commented out functionality #}
{% block portfolio_content %}
{% endblock %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block wrapper %}
<div id="wrapper" class="dashboard--portfolio">
{% block content %}
<main id="main-content" class="grid-container">
{% if user.is_authenticated %}
{# the entire logged in page goes here #}
<div class="tablet:grid-col-12">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
{% block portfolio_content %}{% endblock %}
</div>
{% else %} {# not user.is_authenticated #}
{# the entire logged out page goes here #}
<p><a class="usa-button" href="{% url 'login' %}">
Sign in
</a></p>
{% endif %}
</main>
{% endblock %}
<div role="complementary">{% block complementary %}{% endblock %}</div>
{% block content_bottom %}{% endblock %}
</div>
{% endblock wrapper %}

View file

@ -1,7 +1,9 @@
{% extends 'portfolio.html' %} {% extends 'portfolio_base.html' %}
{% load static %} {% load static %}
{% block title %} Domains | {% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<h1 id="domains-header">Domains</h1> <h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio %} {% include "includes/domains_table.html" with portfolio=portfolio %}

View file

@ -1,7 +1,9 @@
{% extends 'portfolio.html' %} {% extends 'portfolio_base.html' %}
{% load static %} {% load static %}
{% block title %} Domain requests | {% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<h1 id="domain-requests-header">Domain requests</h1> <h1 id="domain-requests-header">Domain requests</h1>

View file

@ -1,37 +0,0 @@
{% load static url_helpers %}
<div class="margin-bottom-4 tablet:margin-bottom-0">
<nav aria-label="">
<h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2>
<ul class="usa-sidenav usa-sidenav--portfolio">
<li class="usa-sidenav__item">
{% url 'portfolio-domains' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
Domains
</a>
</li>
<li class="usa-sidenav__item">
{% url 'portfolio-domain-requests' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
Domain requests
</a>
</li>
<li class="usa-sidenav__item">
<a href="#">
Members
</a>
</li>
<li class="usa-sidenav__item">
<a href="#">
Organization
</a>
</li>
<li class="usa-sidenav__item">
<a href="#">
Senior official
</a>
</li>
</ul>
</nav>
</div>

View file

@ -6,8 +6,8 @@ Edit your User Profile |
{% load static url_helpers %} {% load static url_helpers %}
{# Disable the redirect #} {# Disable the redirect #}
{% block logo %} {% block header %}
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %} {% include "includes/header_selector.html" with logo_clickable=user_finished_setup %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View file

@ -59,7 +59,7 @@ from epplibwrapper import (
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
from waffle.decorators import flag_is_active, waffle_flag from waffle.decorators import waffle_flag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -102,13 +102,6 @@ class DomainBaseView(DomainPermissionView):
domain_pk = "domain:" + str(self.kwargs.get("pk")) domain_pk = "domain:" + str(self.kwargs.get("pk"))
self.session[domain_pk] = self.object self.session[domain_pk] = self.object
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainFormBaseView(DomainBaseView, FormMixin): class DomainFormBaseView(DomainBaseView, FormMixin):
""" """

View file

@ -228,10 +228,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
if request.path_info == self.NEW_URL_NAME: if request.path_info == self.NEW_URL_NAME:
# Clear context so the prop getter won't create a request here. # Clear context so the prop getter won't create a request here.
# Creating a request will be handled in the post method for the # Creating a request will be handled in the post method for the
# intro page. Only TEMPORARY context needed is has_profile_flag # intro page.
has_profile_flag = flag_is_active(self.request, "profile_feature") return render(request, "domain_request_intro.html", {})
context_stuff = {"has_profile_feature_flag": has_profile_flag}
return render(request, "domain_request_intro.html", context=context_stuff)
else: else:
return self.goto(self.steps.first) return self.goto(self.steps.first)
@ -380,7 +378,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def get_context_data(self): def get_context_data(self):
"""Define context for access on all wizard pages.""" """Define context for access on all wizard pages."""
has_profile_flag = flag_is_active(self.request, "profile_feature")
context_stuff = {} context_stuff = {}
if DomainRequest._form_complete(self.domain_request, self.request): if DomainRequest._form_complete(self.domain_request, self.request):
@ -397,8 +394,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"modal_description": "Once you submit this request, you wont be able to edit it until we review it.\ "modal_description": "Once you submit this request, you wont be able to edit it until we review it.\
Youll only be able to withdraw your request.", Youll only be able to withdraw your request.",
"review_form_is_complete": True, "review_form_is_complete": True,
# Use the profile waffle feature flag to toggle profile features throughout domain requests
"has_profile_feature_flag": has_profile_flag,
"user": self.request.user, "user": self.request.user,
} }
else: # form is not complete else: # form is not complete
@ -414,7 +409,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"modal_description": 'This request cannot be submitted yet.\ "modal_description": 'This request cannot be submitted yet.\
Return to the request and visit the steps that are marked as "incomplete."', Return to the request and visit the steps that are marked as "incomplete."',
"review_form_is_complete": False, "review_form_is_complete": False,
"has_profile_feature_flag": has_profile_flag,
"user": self.request.user, "user": self.request.user,
} }
return context_stuff return context_stuff
@ -740,13 +734,6 @@ class Finished(DomainRequestWizard):
class DomainRequestStatus(DomainRequestPermissionView): class DomainRequestStatus(DomainRequestPermissionView):
template_name = "domain_request_status.html" template_name = "domain_request_status.html"
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView): class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
"""This page will ask user to confirm if they want to withdraw """This page will ask user to confirm if they want to withdraw
@ -757,13 +744,6 @@ class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
template_name = "domain_request_withdraw_confirmation.html" template_name = "domain_request_withdraw_confirmation.html"
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView): class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView):
# this view renders no template # this view renders no template

View file

@ -1,3 +1,4 @@
import logging
from django.http import JsonResponse from django.http import JsonResponse
from django.core.paginator import Paginator from django.core.paginator import Paginator
from registrar.models import UserDomainRole, Domain from registrar.models import UserDomainRole, Domain
@ -5,89 +6,29 @@ from django.contrib.auth.decorators import login_required
from django.urls import reverse from django.urls import reverse
from django.db.models import Q from django.db.models import Q
logger = logging.getLogger(__name__)
@login_required @login_required
def get_domains_json(request): def get_domains_json(request):
"""Given the current request, """Given the current request,
get all domains that are associated with the UserDomainRole object""" get all domains that are associated with the UserDomainRole object"""
user_domain_roles = UserDomainRole.objects.filter(user=request.user) user_domain_roles = UserDomainRole.objects.filter(user=request.user).select_related("domain_info__sub_organization")
domain_ids = user_domain_roles.values_list("domain_id", flat=True) domain_ids = user_domain_roles.values_list("domain_id", flat=True)
objects = Domain.objects.filter(id__in=domain_ids) objects = Domain.objects.filter(id__in=domain_ids)
unfiltered_total = objects.count() unfiltered_total = objects.count()
# Handle sorting objects = apply_search(objects, request)
sort_by = request.GET.get("sort_by", "id") # Default to 'id' objects = apply_state_filter(objects, request)
order = request.GET.get("order", "asc") # Default to 'asc' objects = apply_sorting(objects, request)
# Handle search term
search_term = request.GET.get("search_term")
if search_term:
objects = objects.filter(Q(name__icontains=search_term))
# Handle state
status_param = request.GET.get("status")
if status_param:
status_list = status_param.split(",")
# if unknown is in status_list, append 'dns needed' since both
# unknown and dns needed display as DNS Needed, and both are
# searchable via state parameter of 'unknown'
if "unknown" in status_list:
status_list.append("dns needed")
# Split the status list into normal states and custom states
normal_states = [state for state in status_list if state in Domain.State.values]
custom_states = [state for state in status_list if state == "expired"]
# Construct Q objects for normal states that can be queried through ORM
state_query = Q()
if normal_states:
state_query |= Q(state__in=normal_states)
# Handle custom states in Python, as expired can not be queried through ORM
if "expired" in custom_states:
expired_domain_ids = [domain.id for domain in objects if domain.state_display() == "Expired"]
state_query |= Q(id__in=expired_domain_ids)
# Apply the combined query
objects = objects.filter(state_query)
# If there are filtered states, and expired is not one of them, domains with
# state_display of 'Expired' must be removed
if "expired" not in custom_states:
expired_domain_ids = [domain.id for domain in objects if domain.state_display() == "Expired"]
objects = objects.exclude(id__in=expired_domain_ids)
if sort_by == "state_display":
# Fetch the objects and sort them in Python
objects = list(objects) # Evaluate queryset to a list
objects.sort(key=lambda domain: domain.state_display(), reverse=(order == "desc"))
else:
if order == "desc":
sort_by = f"-{sort_by}"
objects = objects.order_by(sort_by)
paginator = Paginator(objects, 10) paginator = Paginator(objects, 10)
page_number = request.GET.get("page") page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
# Convert objects to JSON-serializable format domains = [serialize_domain(domain) for domain in page_obj.object_list]
domains = [
{
"id": domain.id,
"name": domain.name,
"expiration_date": domain.expiration_date,
"state": domain.state,
"state_display": domain.state_display(),
"get_state_help_text": domain.get_state_help_text(),
"action_url": reverse("domain", kwargs={"pk": domain.id}),
"action_label": ("View" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "Manage"),
"svg_icon": ("visibility" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "settings"),
}
for domain in page_obj.object_list
]
return JsonResponse( return JsonResponse(
{ {
@ -100,3 +41,80 @@ def get_domains_json(request):
"unfiltered_total": unfiltered_total, "unfiltered_total": unfiltered_total,
} }
) )
def apply_search(queryset, request):
search_term = request.GET.get("search_term")
if search_term:
queryset = queryset.filter(Q(name__icontains=search_term))
return queryset
def apply_state_filter(queryset, request):
status_param = request.GET.get("status")
if status_param:
status_list = status_param.split(",")
# if unknown is in status_list, append 'dns needed' since both
# unknown and dns needed display as DNS Needed, and both are
# searchable via state parameter of 'unknown'
if "unknown" in status_list:
status_list.append("dns needed")
# Split the status list into normal states and custom states
normal_states = [state for state in status_list if state in Domain.State.values]
custom_states = [state for state in status_list if state == "expired"]
# Construct Q objects for normal states that can be queried through ORM
state_query = Q()
if normal_states:
state_query |= Q(state__in=normal_states)
# Handle custom states in Python, as expired can not be queried through ORM
if "expired" in custom_states:
expired_domain_ids = [domain.id for domain in queryset if domain.state_display() == "Expired"]
state_query |= Q(id__in=expired_domain_ids)
# Apply the combined query
queryset = queryset.filter(state_query)
# If there are filtered states, and expired is not one of them, domains with
# state_display of 'Expired' must be removed
if "expired" not in custom_states:
expired_domain_ids = [domain.id for domain in queryset if domain.state_display() == "Expired"]
queryset = queryset.exclude(id__in=expired_domain_ids)
return queryset
def apply_sorting(queryset, request):
sort_by = request.GET.get("sort_by", "id")
order = request.GET.get("order", "asc")
if sort_by == "state_display":
objects = list(queryset)
objects.sort(key=lambda domain: domain.state_display(), reverse=(order == "desc"))
return objects
else:
if order == "desc":
sort_by = f"-{sort_by}"
return queryset.order_by(sort_by)
def serialize_domain(domain):
suborganization_name = None
try:
domain_info = domain.domain_info
if domain_info:
suborganization = domain_info.sub_organization
if suborganization:
suborganization_name = suborganization.name
except Domain.domain_info.RelatedObjectDoesNotExist:
domain_info = None
logger.debug(f"Issue in domains_json: We could not find domain_info for {domain}")
return {
"id": domain.id,
"name": domain.name,
"expiration_date": domain.expiration_date,
"state": domain.state,
"state_display": domain.state_display(),
"get_state_help_text": domain.get_state_help_text(),
"action_url": reverse("domain", kwargs={"pk": domain.id}),
"action_label": ("View" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "Manage"),
"svg_icon": ("visibility" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "settings"),
"suborganization": suborganization_name,
}

View file

@ -1,5 +1,4 @@
from django.shortcuts import render from django.shortcuts import render
from waffle.decorators import flag_is_active
def index(request): def index(request):
@ -7,10 +6,6 @@ def index(request):
context = {} context = {}
if request.user.is_authenticated: if request.user.is_authenticated:
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
# This controls the creation of a new domain request in the wizard # This controls the creation of a new domain request in the wizard
request.session["new_request"] = True request.session["new_request"] = True

View file

@ -1,6 +1,4 @@
from django.shortcuts import get_object_or_404, render from django.shortcuts import render
from registrar.models.portfolio import Portfolio
from waffle.decorators import flag_is_active
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@ -8,15 +6,6 @@ from django.contrib.auth.decorators import login_required
def portfolio_domains(request, portfolio_id): def portfolio_domains(request, portfolio_id):
context = {} context = {}
if request.user.is_authenticated:
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
# Retrieve the portfolio object based on the provided portfolio_id
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
context["portfolio"] = portfolio
return render(request, "portfolio_domains.html", context) return render(request, "portfolio_domains.html", context)
@ -25,14 +14,6 @@ def portfolio_domain_requests(request, portfolio_id):
context = {} context = {}
if request.user.is_authenticated: if request.user.is_authenticated:
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
# Retrieve the portfolio object based on the provided portfolio_id
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
context["portfolio"] = portfolio
# This controls the creation of a new domain request in the wizard # This controls the creation of a new domain request in the wizard
request.session["new_request"] = True request.session["new_request"] = True

View file

@ -11,7 +11,7 @@ from django.urls import NoReverseMatch, reverse
from registrar.models.user import User from registrar.models.user import User
from registrar.models.utility.generic_helper import replace_url_queryparams from registrar.models.utility.generic_helper import replace_url_queryparams
from registrar.views.utility.permission_views import UserProfilePermissionView from registrar.views.utility.permission_views import UserProfilePermissionView
from waffle.decorators import flag_is_active, waffle_flag from waffle.decorators import waffle_flag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -51,10 +51,8 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Extend get_context_data to include has_profile_feature_flag""" """Extend get_context_data"""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
# Set the profile_back_button_text based on the redirect parameter # Set the profile_back_button_text based on the redirect parameter
if kwargs.get("redirect") == "domain-request:": if kwargs.get("redirect") == "domain-request:":
@ -134,7 +132,7 @@ class FinishProfileSetupView(UserProfileView):
base_view_name = "finish-user-profile-setup" base_view_name = "finish-user-profile-setup"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Extend get_context_data to include has_profile_feature_flag""" """Extend get_context_data"""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# Show back button conditional on user having finished setup # Show back button conditional on user having finished setup

View file

@ -14,14 +14,12 @@ Rather than dealing with that, we keep everything centralized in one location.
""" """
from django.shortcuts import render from django.shortcuts import render
from waffle.decorators import flag_is_active
def custom_500_error_view(request, context=None): def custom_500_error_view(request, context=None):
"""Used to redirect 500 errors to a custom view""" """Used to redirect 500 errors to a custom view"""
if context is None: if context is None:
context = {} context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "500.html", context=context, status=500) return render(request, "500.html", context=context, status=500)
@ -29,7 +27,6 @@ def custom_401_error_view(request, context=None):
"""Used to redirect 401 errors to a custom view""" """Used to redirect 401 errors to a custom view"""
if context is None: if context is None:
context = {} context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "401.html", context=context, status=401) return render(request, "401.html", context=context, status=401)
@ -37,5 +34,4 @@ def custom_403_error_view(request, exception=None, context=None):
"""Used to redirect 403 errors to a custom view""" """Used to redirect 403 errors to a custom view"""
if context is None: if context is None:
context = {} context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "403.html", context=context, status=403) return render(request, "403.html", context=context, status=403)