This commit is contained in:
Rachid Mrad 2024-06-24 15:02:56 -04:00
commit ff37b3bc9b
No known key found for this signature in database
19 changed files with 316 additions and 92 deletions

View file

@ -1233,7 +1233,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
search_help_text = "Search by domain."
fieldsets = [
(None, {"fields": ["creator", "submitter", "domain_request", "notes"]}),
(None, {"fields": ["portfolio", "creator", "submitter", "domain_request", "notes"]}),
(".gov domain", {"fields": ["domain"]}),
("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}),
("Background info", {"fields": ["anything_else"]}),
@ -1319,6 +1319,32 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
change_form_template = "django/admin/domain_information_change_form.html"
superuser_only_fields = [
"portfolio",
]
# DEVELOPER's NOTE:
# Normally, to exclude a field from an Admin form, we could simply utilize
# Django's "exclude" feature. However, it causes a "missing key" error if we
# go that route for this particular form. The error gets thrown by our
# custom fieldset.html code and is due to the fact that "exclude" removes
# fields from base_fields but not fieldsets. Rather than reworking our
# custom frontend, it seems more straightforward (and easier to read) to simply
# modify the fieldsets list so that it excludes any fields we want to remove
# based on permissions (eg. superuser_only_fields) or other conditions.
def get_fieldsets(self, request, obj=None):
fieldsets = self.fieldsets
# Create a modified version of fieldsets to exclude certain fields
if not request.user.has_perm("registrar.full_access_permission"):
modified_fieldsets = []
for name, data in fieldsets:
fields = data.get("fields", [])
fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields)
modified_fieldsets.append((name, {"fields": fields}))
return modified_fieldsets
return fieldsets
def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements.
We have 1 conditions that determine which fields are read-only:
@ -1482,6 +1508,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
None,
{
"fields": [
"portfolio",
"status",
"rejection_reason",
"action_needed_reason",
@ -1592,6 +1619,32 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
superuser_only_fields = [
"portfolio",
]
# DEVELOPER's NOTE:
# Normally, to exclude a field from an Admin form, we could simply utilize
# Django's "exclude" feature. However, it causes a "missing key" error if we
# go that route for this particular form. The error gets thrown by our
# custom fieldset.html code and is due to the fact that "exclude" removes
# fields from base_fields but not fieldsets. Rather than reworking our
# custom frontend, it seems more straightforward (and easier to read) to simply
# modify the fieldsets list so that it excludes any fields we want to remove
# based on permissions (eg. superuser_only_fields) or other conditions.
def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj)
# Create a modified version of fieldsets to exclude certain fields
if not request.user.has_perm("registrar.full_access_permission"):
modified_fieldsets = []
for name, data in fieldsets:
fields = data.get("fields", [])
fields = tuple(field for field in fields if field not in self.superuser_only_fields)
modified_fieldsets.append((name, {"fields": fields}))
return modified_fieldsets
return fieldsets
# Table ordering
# NOTE: This impacts the select2 dropdowns (combobox)
# Currentl, there's only one for requests on DomainInfo
@ -1937,13 +1990,7 @@ class DomainInformationInline(admin.StackedInline):
template = "django/admin/includes/domain_info_inline_stacked.html"
model = models.DomainInformation
fieldsets = copy.deepcopy(DomainInformationAdmin.fieldsets)
# remove .gov domain from fieldset
for index, (title, f) in enumerate(fieldsets):
if title == ".gov domain":
del fieldsets[index]
break
fieldsets = DomainInformationAdmin.fieldsets
readonly_fields = DomainInformationAdmin.readonly_fields
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
@ -1990,6 +2037,23 @@ class DomainInformationInline(admin.StackedInline):
def get_readonly_fields(self, request, obj=None):
return DomainInformationAdmin.get_readonly_fields(self, request, obj=None)
# Re-route the get_fieldsets method to utilize DomainInformationAdmin.get_fieldsets
# since that has all the logic for excluding certain fields according to user permissions.
# Then modify the remaining fields to further trim out any we don't want for this inline
# form
def get_fieldsets(self, request, obj=None):
# Grab fieldsets from DomainInformationAdmin so that it handles all logic
# for permission-based field visibility.
modified_fieldsets = DomainInformationAdmin.get_fieldsets(self, request, obj=None)
# remove .gov domain from fieldset
for index, (title, f) in enumerate(modified_fieldsets):
if title == ".gov domain":
del modified_fieldsets[index]
break
return modified_fieldsets
class DomainResource(FsmModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -2656,6 +2720,14 @@ class WaffleFlagAdmin(FlagAdmin):
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.register(LogEntry, CustomLogEntryAdmin)
@ -2679,6 +2751,8 @@ admin.site.register(models.DomainRequest, DomainRequestAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
admin.site.register(models.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin)
# Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)

View file

@ -33,6 +33,33 @@ const showElement = (element) => {
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. */
function makeHidden(el) {
el.style.position = "absolute";
@ -1407,6 +1434,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
*/
function deleteDomainRequest(domainRequestPk,pageToDisplay) {
// Use to debug uswds modal issues
//console.log('deleteDomainRequest')
// Get csrf token
const csrfToken = getCsrfToken();
// Create FormData object and append the CSRF token
@ -1461,6 +1492,14 @@ document.addEventListener('DOMContentLoaded', function() {
const tbody = document.querySelector('.domain-requests__table tbody');
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
// after the DOM content changes and there are new delete modal buttons added
unloadModals();
@ -1582,7 +1621,7 @@ document.addEventListener('DOMContentLoaded', function() {
</svg>
</button>
</div>
`
`
domainRequestsSectionWrapper.appendChild(modal);
}

View file

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

View file

@ -25,7 +25,7 @@ from registrar.views.domain_request import Step
from registrar.views.domain_requests_json import get_domain_requests_json
from registrar.views.domains_json import get_domains_json
from registrar.views.utility import always_404
from registrar.views.organizations import organization_domains, organization_domain_requests
from registrar.views.portfolios import portfolio_domains, portfolio_domain_requests
from api.views import available, get_current_federal, get_current_full
@ -60,14 +60,14 @@ for step, view in [
urlpatterns = [
path("", views.index, name="home"),
path(
"organization/<int:portfolio_id>/domains/",
organization_domains,
name="organization-domains",
"portfolio/<int:portfolio_id>/domains/",
portfolio_domains,
name="portfolio-domains",
),
path(
"organization/<int:portfolio_id>/domain_requests/",
organization_domain_requests,
name="organization-domain-requests",
"portfolio/<int:portfolio_id>/domain_requests/",
portfolio_domain_requests,
name="portfolio-domain-requests",
),
path(
"admin/logout/",

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 .waffle_flag import WaffleFlag
from .portfolio import Portfolio
from .domain_group import DomainGroup
from .suborganization import Suborganization
__all__ = [
@ -38,6 +40,8 @@ __all__ = [
"VerifiedByStaff",
"WaffleFlag",
"Portfolio",
"DomainGroup",
"Suborganization",
]
auditlog.register(Contact)
@ -58,3 +62,5 @@ auditlog.register(TransitionDomain)
auditlog.register(VerifiedByStaff)
auditlog.register(WaffleFlag)
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

@ -97,3 +97,6 @@ class Portfolio(TimeStampedModel):
verbose_name="security contact e-mail",
max_length=320,
)
def __str__(self) -> str:
return f"{self.organization_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

@ -125,10 +125,10 @@ class CheckUserProfileMiddleware:
return None
class CheckOrganizationMiddleware:
class CheckPortfolioMiddleware:
"""
Checks if the current user has a portfolio
If they do, redirect them to the org homepage when they navigate to home.
If they do, redirect them to the portfolio homepage when they navigate to home.
"""
def __init__(self, get_response):
@ -150,8 +150,6 @@ class CheckOrganizationMiddleware:
user_portfolios = Portfolio.objects.filter(creator=request.user)
if user_portfolios.exists():
first_portfolio = user_portfolios.first()
home_organization_with_portfolio = reverse(
"organization-domains", kwargs={"portfolio_id": first_portfolio.id}
)
return HttpResponseRedirect(home_organization_with_portfolio)
home_with_portfolio = reverse("portfolio-domains", kwargs={"portfolio_id": first_portfolio.id})
return HttpResponseRedirect(home_with_portfolio)
return None

View file

@ -16,7 +16,7 @@
</legend>
<!-- Toggle -->
<em>Select one (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.0.has_cisa_representative %}
{% endwith %}
@ -36,7 +36,7 @@
</legend>
<!-- Toggle -->
<em>Select one (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.2.has_anything_else_text %}
{% endwith %}
@ -44,7 +44,7 @@
</fieldset>
<div class="margin-top-3" id="anything-else">
<p>Provide details below (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</p>
<p>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p>
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.3.anything_else %}
{% endwith %}

View file

@ -1,43 +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 "organization_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 organization_content %}
{% endblock %}
{# 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>
-->
</div>
</div>
{% endblock %}

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

@ -1,8 +1,8 @@
{% extends 'organization.html' %}
{% extends 'portfolio.html' %}
{% load static %}
{% block organization_content %}
{% block portfolio_content %}
<h1>Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio %}
{% endblock %}

View file

@ -1,8 +1,8 @@
{% extends 'organization.html' %}
{% extends 'portfolio.html' %}
{% load static %}
{% block organization_content %}
{% block portfolio_content %}
<h1>Domain requests</h1>
{% comment %}

View file

@ -5,14 +5,14 @@
<h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2>
<ul class="usa-sidenav usa-sidenav--portfolio">
<li class="usa-sidenav__item">
{% url 'organization-domains' portfolio.id as url %}
{% 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 'organization-domain-requests' portfolio.id as url %}
{% url 'portfolio-domain-requests' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
Domain requests
</a>

View file

@ -908,7 +908,7 @@ class UserProfileTests(TestWithUser, WebTest):
self.assertContains(profile_page, "Your profile has been updated")
class OrganizationsTests(TestWithUser, WebTest):
class PortfoliosTests(TestWithUser, WebTest):
"""A series of tests that target the organizations"""
# csrf checks do not work well with WebTest.
@ -939,33 +939,33 @@ class OrganizationsTests(TestWithUser, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@less_console_noise_decorator
def test_middleware_redirects_to_organization_homepage(self):
"""Tests that a user is redirected to the org homepage when organization_feature is on and
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 org page.
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
org_page = self.app.get(reverse("home")).follow()
portfolio_page = self.app.get(reverse("home")).follow()
self._set_session_cookie()
# Assert that we're on the right page
self.assertContains(org_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(org_page, "<h1>Domains</h1>")
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)
org_page = self.app.get(reverse("home"))
home_page = self.app.get(reverse("home"))
self._set_session_cookie()
self.assertNotContains(org_page, self.portfolio.organization_name)
self.assertNotContains(home_page, self.portfolio.organization_name)
self.assertContains(org_page, 'id="domain-requests-header"')
self.assertContains(home_page, 'id="domain-requests-header"')
@less_console_noise_decorator
def test_no_redirect_when_user_has_no_portfolios(self):
@ -974,9 +974,9 @@ class OrganizationsTests(TestWithUser, WebTest):
self.portfolio.delete()
self.app.set_user(self.user.username)
with override_flag("organization_feature", active=True):
org_page = self.app.get(reverse("home"))
home_page = self.app.get(reverse("home"))
self._set_session_cookie()
self.assertNotContains(org_page, self.portfolio.organization_name)
self.assertNotContains(home_page, self.portfolio.organization_name)
self.assertContains(org_page, 'id="domain-requests-header"')
self.assertContains(home_page, 'id="domain-requests-header"')

View file

@ -5,7 +5,7 @@ from django.contrib.auth.decorators import login_required
@login_required
def organization_domains(request, portfolio_id):
def portfolio_domains(request, portfolio_id):
context = {}
if request.user.is_authenticated:
@ -17,11 +17,11 @@ def organization_domains(request, portfolio_id):
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
context["portfolio"] = portfolio
return render(request, "organization_domains.html", context)
return render(request, "portfolio_domains.html", context)
@login_required
def organization_domain_requests(request, portfolio_id):
def portfolio_domain_requests(request, portfolio_id):
context = {}
if request.user.is_authenticated:
@ -36,4 +36,4 @@ def organization_domain_requests(request, portfolio_id):
# This controls the creation of a new domain request in the wizard
request.session["new_request"] = True
return render(request, "organization_requests.html", context)
return render(request, "portfolio_requests.html", context)