Merge branch 'main' of https://github.com/cisagov/manage.get.gov into rh/2258-update-ao-to-so

This commit is contained in:
Rebecca Hsieh 2024-06-24 11:52:38 -07:00
commit 6e1669ddf8
No known key found for this signature in database
21 changed files with 622 additions and 207 deletions

View file

@ -2720,6 +2720,14 @@ class WaffleFlagAdmin(FlagAdmin):
fields = "__all__" fields = "__all__"
class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["name", "portfolio"]
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["name", "portfolio"]
admin.site.unregister(LogEntry) # Unregister the default registration admin.site.unregister(LogEntry) # Unregister the default registration
admin.site.register(LogEntry, CustomLogEntryAdmin) admin.site.register(LogEntry, CustomLogEntryAdmin)
@ -2743,6 +2751,8 @@ admin.site.register(models.DomainRequest, DomainRequestAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
admin.site.register(models.Portfolio, PortfolioAdmin) admin.site.register(models.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin)
# Register our custom waffle implementations # Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin) admin.site.register(models.WaffleFlag, WaffleFlagAdmin)

View file

@ -33,6 +33,33 @@ const showElement = (element) => {
element.classList.remove('display-none'); element.classList.remove('display-none');
}; };
/**
* Helper function that scrolls to an element
* @param {string} attributeName - The string "class" or "id"
* @param {string} attributeValue - The class or id name
*/
function ScrollToElement(attributeName, attributeValue) {
let targetEl = null;
if (attributeName === 'class') {
targetEl = document.getElementsByClassName(attributeValue)[0];
} else if (attributeName === 'id') {
targetEl = document.getElementById(attributeValue);
} else {
console.log('Error: unknown attribute name provided.');
return; // Exit the function if an invalid attributeName is provided
}
if (targetEl) {
const rect = targetEl.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
window.scrollTo({
top: rect.top + scrollTop,
behavior: 'smooth' // Optional: for smooth scrolling
});
}
}
/** Makes an element invisible. */ /** Makes an element invisible. */
function makeHidden(el) { function makeHidden(el) {
el.style.position = "absolute"; el.style.position = "absolute";
@ -895,33 +922,6 @@ function unloadModals() {
window.modal.off(); window.modal.off();
} }
/**
* Helper function that scrolls to an element
* @param {string} attributeName - The string "class" or "id"
* @param {string} attributeValue - The class or id name
*/
function ScrollToElement(attributeName, attributeValue) {
let targetEl = null;
if (attributeName === 'class') {
targetEl = document.getElementsByClassName(attributeValue)[0];
} else if (attributeName === 'id') {
targetEl = document.getElementById(attributeValue);
} else {
console.log('Error: unknown attribute name provided.');
return; // Exit the function if an invalid attributeName is provided
}
if (targetEl) {
const rect = targetEl.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
window.scrollTo({
top: rect.top + scrollTop,
behavior: 'smooth' // Optional: for smooth scrolling
});
}
}
/** /**
* Generalized function to update pagination for a list. * Generalized function to update pagination for a list.
* @param {string} itemName - The name displayed in the counter * @param {string} itemName - The name displayed in the counter
@ -1294,6 +1294,10 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page * @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page
*/ */
function deleteDomainRequest(domainRequestPk,pageToDisplay) { function deleteDomainRequest(domainRequestPk,pageToDisplay) {
// Use to debug uswds modal issues
//console.log('deleteDomainRequest')
// Get csrf token // Get csrf token
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
// Create FormData object and append the CSRF token // Create FormData object and append the CSRF token
@ -1348,6 +1352,14 @@ document.addEventListener('DOMContentLoaded', function() {
const tbody = document.querySelector('.domain-requests__table tbody'); const tbody = document.querySelector('.domain-requests__table tbody');
tbody.innerHTML = ''; tbody.innerHTML = '';
// Unload modals will re-inject the DOM with the initial placeholders to allow for .on() in regular use cases
// We do NOT want that as it will cause multiple placeholders and therefore multiple inits on delete,
// which will cause bad delete requests to be sent.
const preExistingModalPlaceholders = document.querySelectorAll('[data-placeholder-for^="toggle-delete-domain-alert"]');
preExistingModalPlaceholders.forEach(element => {
element.remove();
});
// remove any existing modal elements from the DOM so they can be properly re-initialized // remove any existing modal elements from the DOM so they can be properly re-initialized
// after the DOM content changes and there are new delete modal buttons added // after the DOM content changes and there are new delete modal buttons added
unloadModals(); unloadModals();
@ -1469,7 +1481,7 @@ document.addEventListener('DOMContentLoaded', function() {
</svg> </svg>
</button> </button>
</div> </div>
` `
domainRequestsSectionWrapper.appendChild(modal); domainRequestsSectionWrapper.appendChild(modal);
} }

View file

@ -189,6 +189,7 @@ MIDDLEWARE = [
# Used for waffle feature flags # Used for waffle feature flags
"waffle.middleware.WaffleMiddleware", "waffle.middleware.WaffleMiddleware",
"registrar.registrar_middleware.CheckUserProfileMiddleware", "registrar.registrar_middleware.CheckUserProfileMiddleware",
"registrar.registrar_middleware.CheckPortfolioMiddleware",
] ]
# application object used by Djangos built-in servers (e.g. `runserver`) # application object used by Djangos built-in servers (e.g. `runserver`)

View file

@ -25,6 +25,7 @@ from registrar.views.domain_request import Step
from registrar.views.domain_requests_json import get_domain_requests_json from registrar.views.domain_requests_json import get_domain_requests_json
from registrar.views.domains_json import get_domains_json from registrar.views.domains_json import get_domains_json
from registrar.views.utility import always_404 from registrar.views.utility import always_404
from registrar.views.portfolios import portfolio_domains, portfolio_domain_requests
from api.views import available, get_current_federal, get_current_full from api.views import available, get_current_federal, get_current_full
@ -58,6 +59,16 @@ for step, view in [
urlpatterns = [ urlpatterns = [
path("", views.index, name="home"), path("", views.index, name="home"),
path(
"portfolio/<int:portfolio_id>/domains/",
portfolio_domains,
name="portfolio-domains",
),
path(
"portfolio/<int:portfolio_id>/domain_requests/",
portfolio_domain_requests,
name="portfolio-domain-requests",
),
path( path(
"admin/logout/", "admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False), RedirectView.as_view(pattern_name="logout", permanent=False),

View file

@ -0,0 +1,41 @@
# Generated by Django 4.2.10 on 2024-06-21 18:15
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0104_create_groups_v13"),
]
operations = [
migrations.CreateModel(
name="Suborganization",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(help_text="Suborganization", max_length=1000, unique=True)),
("portfolio", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="registrar.portfolio")),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="DomainGroup",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(help_text="Domain group", unique=True)),
("domains", models.ManyToManyField(blank=True, to="registrar.domaininformation")),
("portfolio", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="registrar.portfolio")),
],
options={
"unique_together": {("name", "portfolio")},
},
),
]

View file

@ -0,0 +1,37 @@
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
# It is dependent on 0079 (which populates federal agencies)
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
# in the user_group model then:
# [NOT RECOMMENDED]
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
# step 3: fake run the latest migration in the migrations list
# [RECOMMENDED]
# Alternatively:
# step 1: duplicate the migration that loads data
# step 2: docker-compose exec app ./manage.py migrate
from django.db import migrations
from registrar.models import UserGroup
from typing import Any
# For linting: RunPython expects a function reference,
# so let's give it one
def create_groups(apps, schema_editor) -> Any:
UserGroup.create_cisa_analyst_group(apps, schema_editor)
UserGroup.create_full_access_group(apps, schema_editor)
class Migration(migrations.Migration):
dependencies = [
("registrar", "0105_suborganization_domaingroup"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -17,6 +17,8 @@ from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff from .verified_by_staff import VerifiedByStaff
from .waffle_flag import WaffleFlag from .waffle_flag import WaffleFlag
from .portfolio import Portfolio from .portfolio import Portfolio
from .domain_group import DomainGroup
from .suborganization import Suborganization
__all__ = [ __all__ = [
@ -38,6 +40,8 @@ __all__ = [
"VerifiedByStaff", "VerifiedByStaff",
"WaffleFlag", "WaffleFlag",
"Portfolio", "Portfolio",
"DomainGroup",
"Suborganization",
] ]
auditlog.register(Contact) auditlog.register(Contact)
@ -58,3 +62,5 @@ auditlog.register(TransitionDomain)
auditlog.register(VerifiedByStaff) auditlog.register(VerifiedByStaff)
auditlog.register(WaffleFlag) auditlog.register(WaffleFlag)
auditlog.register(Portfolio) auditlog.register(Portfolio)
auditlog.register(DomainGroup)
auditlog.register(Suborganization)

View file

@ -0,0 +1,23 @@
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
class DomainGroup(TimeStampedModel):
"""
Organized group of domains.
"""
class Meta:
unique_together = [("name", "portfolio")]
name = models.CharField(
unique=True,
help_text="Domain group",
)
portfolio = models.ForeignKey("registrar.Portfolio", on_delete=models.PROTECT)
domains = models.ManyToManyField("registrar.DomainInformation", blank=True)
def __str__(self) -> str:
return f"{self.name}"

View file

@ -0,0 +1,22 @@
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
class Suborganization(TimeStampedModel):
"""
Suborganization under an organization (portfolio)
"""
name = models.CharField(
unique=True,
max_length=1000,
help_text="Suborganization",
)
portfolio = models.ForeignKey(
"registrar.Portfolio",
on_delete=models.PROTECT,
)
def __str__(self) -> str:
return f"{self.name}"

View file

@ -2,14 +2,18 @@
Contains middleware used in settings.py Contains middleware used in settings.py
""" """
import logging
from urllib.parse import parse_qs from urllib.parse import parse_qs
from django.urls import reverse from django.urls import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from registrar.models.portfolio import Portfolio
from registrar.models.user import User from registrar.models.user import User
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
from registrar.models.utility.generic_helper import replace_url_queryparams from registrar.models.utility.generic_helper import replace_url_queryparams
logger = logging.getLogger(__name__)
class NoCacheMiddleware: class NoCacheMiddleware:
""" """
@ -119,3 +123,33 @@ class CheckUserProfileMiddleware:
else: else:
# Process the view as normal # Process the view as normal
return None return None
class CheckPortfolioMiddleware:
"""
Checks if the current user has a portfolio
If they do, redirect them to the portfolio homepage when they navigate to home.
"""
def __init__(self, get_response):
self.get_response = get_response
self.home = reverse("home")
def __call__(self, request):
response = self.get_response(request)
return response
def process_view(self, request, view_func, view_args, view_kwargs):
current_path = request.path
has_organization_feature_flag = flag_is_active(request, "organization_feature")
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})
return HttpResponseRedirect(home_with_portfolio)
return None

View file

@ -9,189 +9,48 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
{# the entire logged in page goes here #} {# the entire logged in page goes here #}
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1"> {% block homepage_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<h1>Manage your domains</h2>
{% comment %} <div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
IMPORTANT: {% block messages %}
If this button is added on any other page, make sure to update the {% include "includes/form_messages.html" %}
relevant view to reset request.session["new_request"] = True {% endblock %}
{% endcomment %} <h1>Manage your domains</h1>
<p class="margin-top-4">
<a href="{% url 'domain-request:' %}" class="usa-button"
>
Start a new domain request
</a>
</p>
<section class="section--outlined domains"> {% comment %}
<div class="grid-row"> IMPORTANT:
<div class="mobile:grid-col-12 desktop:grid-col-6"> If this button is added on any other page, make sure to update the
<h2 id="domains-header" class="flex-6">Domains</h2> relevant view to reset request.session["new_request"] = True
</div> {% endcomment %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <p class="margin-top-4">
<section aria-label="Domains search component" class="flex-6 margin-y-2"> <a href="{% url 'domain-request:' %}" class="usa-button"
<form class="usa-search usa-search--small" method="POST" role="search"> >
{% csrf_token %} Start a new domain request
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-button display-none" type="button"> </a>
Reset </p>
</button>
<label class="usa-sr-only" for="domains__search-field">Search</label>
<input
class="usa-input"
id="domains__search-field"
type="search"
name="search"
placeholder="Search by domain name"
/>
<button class="usa-button" type="submit" id="domains__search-field-submit">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
</div>
<div class="domains__table-wrapper display-none">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table">
<caption class="sr-only">Your registered domains</caption>
<thead>
<tr>
<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="state_display" scope="col" role="columnheader">Status</th>
<th
scope="col"
role="columnheader"
>
<span class="usa-sr-only">Action</span>
</th>
</tr>
</thead>
<tbody>
<!-- AJAX will populate this tbody -->
</tbody>
</table>
<div
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
</div>
<div class="domains__no-data display-none">
<p>You don't have any registered domains.</p>
<p class="maxw-none clearfix">
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
</svg>
Why don't I see my domain when I sign in to the registrar?
</a>
</p>
</div>
<div class="domains__no-search-results display-none">
<p>No results found for "<span class="domains__search-term"></span>"</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
<!-- Count will be dynamically populated by JS -->
</span>
<ul class="usa-pagination__list">
<!-- Pagination links will be dynamically populated by JS -->
</ul>
</nav>
<section class="section--outlined domain-requests"> {% include "includes/domains_table.html" %}
<div class="grid-row"> {% include "includes/domain_requests_table.html" %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div>
<div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-button display-none" type="button">
Reset
</button>
<label class="usa-sr-only" for="domain-requests__search-field">Search</label>
<input
class="usa-input"
id="domain-requests__search-field"
type="search"
name="search"
placeholder="Search by domain name"
/>
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
</div>
<div class="domain-requests__table-wrapper display-none">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
<caption class="sr-only">Your domain requests</caption>
<thead>
<tr>
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th>
<th data-sortable="status" scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
<!-- AJAX will conditionally add a th for delete actions -->
</tr>
</thead>
<tbody id="domain-requests-tbody">
<!-- AJAX will populate this tbody -->
</tbody>
</table>
<div
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
</div>
<div class="domain-requests__no-data display-none">
<p>You haven't requested any domains.</p>
</div>
<div class="domain-requests__no-search-results display-none">
<p>No results found for "<span class="domain-requests__search-term"></span>"</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
<!-- Count will be dynamically populated by JS -->
</span>
<ul class="usa-pagination__list">
<!-- Pagination links will be dynamically populated by JS -->
</ul>
</nav>
{# Note: Reimplement this after MVP #} {# Note: Reimplement this after MVP #}
<!-- <!--
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10"> <section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
<h2>Archived domains</h2> <h2>Archived domains</h2>
<p>You don't have any archived domains</p> <p>You don't have any archived domains</p>
</section> </section>
--> -->
<!-- Note: Uncomment below when this is being implemented post-MVP --> <!-- Note: Uncomment below when this is being implemented post-MVP -->
<!-- <section class="tablet:grid-col-11 desktop:grid-col-10"> <!-- <section class="tablet:grid-col-11 desktop:grid-col-10">
<h2 class="padding-top-1 mobile-lg:padding-top-3"> Export domains</h2> <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> <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"> <a href="{% url 'todo' %}" class="usa-button usa-button--outline">
Export domains as csv Export domains as csv
</a> </a>
</section> </section>
--> -->
{% endblock %}
</div> </div>
{% else %} {# not user.is_authenticated #} {% else %} {# not user.is_authenticated #}

View file

@ -0,0 +1,71 @@
{% load static %}
<section class="section--outlined domain-requests">
<div class="grid-row">
{% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div>
{% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-button display-none" type="button">
Reset
</button>
<label class="usa-sr-only" for="domain-requests__search-field">Search</label>
<input
class="usa-input"
id="domain-requests__search-field"
type="search"
name="search"
placeholder="Search by domain name"
/>
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
</div>
<div class="domain-requests__table-wrapper display-none">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
<caption class="sr-only">Your domain requests</caption>
<thead>
<tr>
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th>
<th data-sortable="status" scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
<!-- AJAX will conditionally add a th for delete actions -->
</tr>
</thead>
<tbody id="domain-requests-tbody">
<!-- AJAX will populate this tbody -->
</tbody>
</table>
<div
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
</div>
<div class="domain-requests__no-data display-none">
<p>You haven't requested any domains.</p>
</div>
<div class="domain-requests__no-search-results display-none">
<p>No results found for "<span class="domain-requests__search-term"></span>"</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
<!-- Count will be dynamically populated by JS -->
</span>
<ul class="usa-pagination__list">
<!-- Pagination links will be dynamically populated by JS -->
</ul>
</nav>

View file

@ -0,0 +1,83 @@
{% load static %}
<section class="section--outlined domains">
<div class="grid-row">
{% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domains-header" class="flex-6">Domains</h2>
</div>
{% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domains search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-button display-none" type="button">
Reset
</button>
<label class="usa-sr-only" for="domains__search-field">Search</label>
<input
class="usa-input"
id="domains__search-field"
type="search"
name="search"
placeholder="Search by domain name"
/>
<button class="usa-button" type="submit" id="domains__search-field-submit">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
</div>
<div class="domains__table-wrapper display-none">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table">
<caption class="sr-only">Your registered domains</caption>
<thead>
<tr>
<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="state_display" scope="col" role="columnheader">Status</th>
<th
scope="col"
role="columnheader"
>
<span class="usa-sr-only">Action</span>
</th>
</tr>
</thead>
<tbody>
<!-- AJAX will populate this tbody -->
</tbody>
</table>
<div
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
</div>
<div class="domains__no-data display-none">
<p>You don't have any registered domains.</p>
<p class="maxw-none clearfix">
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
</svg>
Why don't I see my domain when I sign in to the registrar?
</a>
</p>
</div>
<div class="domains__no-search-results display-none">
<p>No results found for "<span class="domains__search-term"></span>"</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
<!-- Count will be dynamically populated by JS -->
</span>
<ul class="usa-pagination__list">
<!-- Pagination links will be dynamically populated by JS -->
</ul>
</nav>

View file

@ -0,0 +1,24 @@
{% 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,8 @@
{% extends 'portfolio.html' %}
{% load static %}
{% block portfolio_content %}
<h1>Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio %}
{% endblock %}

View file

@ -0,0 +1,21 @@
{% extends 'portfolio.html' %}
{% load static %}
{% block portfolio_content %}
<h1>Domain requests</h1>
{% comment %}
IMPORTANT:
If this button is added on any other page, make sure to update the
relevant view to reset request.session["new_request"] = True
{% endcomment %}
<p class="margin-top-4">
<a href="{% url 'domain-request:' %}" class="usa-button"
>
Start a new domain request
</a>
</p>
{% include "includes/domain_requests_table.html" with portfolio=portfolio %}
{% endblock %}

View file

@ -0,0 +1,37 @@
{% 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">
<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

@ -24,6 +24,7 @@ SAMPLE_KWARGS = {
"object_id": "3", "object_id": "3",
"domain": "whitehouse.gov", "domain": "whitehouse.gov",
"user_pk": "1", "user_pk": "1",
"portfolio_id": "1",
} }
# Our test suite will ignore some namespaces. # Our test suite will ignore some namespaces.

View file

@ -8,6 +8,7 @@ from api.tests.common import less_console_noise_decorator
from registrar.models.contact import Contact from registrar.models.contact import Contact
from registrar.models.domain import Domain from registrar.models.domain import Domain
from registrar.models.draft_domain import DraftDomain from registrar.models.draft_domain import DraftDomain
from registrar.models.portfolio import Portfolio
from registrar.models.public_contact import PublicContact from registrar.models.public_contact import PublicContact
from registrar.models.user import User from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
@ -652,7 +653,6 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
super().tearDown() super().tearDown()
PublicContact.objects.filter(domain=self.domain).delete() PublicContact.objects.filter(domain=self.domain).delete()
self.role.delete() self.role.delete()
self.domain.delete()
Domain.objects.all().delete() Domain.objects.all().delete()
Website.objects.all().delete() Website.objects.all().delete()
Contact.objects.all().delete() Contact.objects.all().delete()
@ -906,3 +906,77 @@ class UserProfileTests(TestWithUser, WebTest):
profile_page = profile_page.follow() profile_page = profile_page.follow()
self.assertEqual(profile_page.status_code, 200) self.assertEqual(profile_page.status_code, 200)
self.assertContains(profile_page, "Your profile has been updated") self.assertContains(profile_page, "Your profile has been updated")
class PortfoliosTests(TestWithUser, WebTest):
"""A series of tests that target the organizations"""
# csrf checks do not work well with WebTest.
# We disable them here.
csrf_checks = False
def setUp(self):
super().setUp()
self.user.save()
self.client.force_login(self.user)
self.domain, _ = Domain.objects.get_or_create(name="sampledomain.gov", state=Domain.State.READY)
self.role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
)
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="xyz inc")
def tearDown(self):
Portfolio.objects.all().delete()
super().tearDown()
PublicContact.objects.filter(domain=self.domain).delete()
UserDomainRole.objects.all().delete()
Domain.objects.all().delete()
Website.objects.all().delete()
Contact.objects.all().delete()
def _set_session_cookie(self):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@less_console_noise_decorator
def test_middleware_redirects_to_portfolio_homepage(self):
"""Tests that a user is redirected to the portfolio homepage when organization_feature is on and
a portfolio belongs to the user, test for the special h1s which only exist in that version
of the homepage"""
self.app.set_user(self.user.username)
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
portfolio_page = self.app.get(reverse("home")).follow()
self._set_session_cookie()
# Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Domains</h1>")
@less_console_noise_decorator
def test_no_redirect_when_org_flag_false(self):
"""No redirect so no follow,
implicitely test for the presense of the h2 by looking up its id"""
self.app.set_user(self.user.username)
home_page = self.app.get(reverse("home"))
self._set_session_cookie()
self.assertNotContains(home_page, self.portfolio.organization_name)
self.assertContains(home_page, 'id="domain-requests-header"')
@less_console_noise_decorator
def test_no_redirect_when_user_has_no_portfolios(self):
"""No redirect so no follow,
implicitely test for the presense of the h2 by looking up its id"""
self.portfolio.delete()
self.app.set_user(self.user.username)
with override_flag("organization_feature", active=True):
home_page = self.app.get(reverse("home"))
self._set_session_cookie()
self.assertNotContains(home_page, self.portfolio.organization_name)
self.assertContains(home_page, 'id="domain-requests-header"')

View file

@ -9,6 +9,7 @@ def index(request):
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 # 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_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

@ -0,0 +1,39 @@
from django.shortcuts import get_object_or_404, render
from registrar.models.portfolio import Portfolio
from waffle.decorators import flag_is_active
from django.contrib.auth.decorators import login_required
@login_required
def portfolio_domains(request, portfolio_id):
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)
@login_required
def portfolio_domain_requests(request, portfolio_id):
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
# This controls the creation of a new domain request in the wizard
request.session["new_request"] = True
return render(request, "portfolio_requests.html", context)