Merge remote-tracking branch 'origin/main' into ms/3212-FEB-purpose-questions

This commit is contained in:
matthewswspence 2025-03-03 16:25:57 -06:00
commit f218ca31ba
No known key found for this signature in database
GPG key ID: FB458202A7852BA4
145 changed files with 5025 additions and 3402 deletions

View file

@ -1,6 +1,6 @@
name: Bug
description: Report a bug or problem with the application
labels: ["bug"]
labels: ["bug","dev"]
body:
- type: markdown

View file

@ -1,8 +1,8 @@
# This workflow can be run from the CLI
# gh workflow run reset-db.yaml -f environment=ENVIRONMENT
name: Reset database
run-name: Reset database for ${{ github.event.inputs.environment }}
name: Delete and Recreate database
run-name: Delete and Recreate for ${{ github.event.inputs.environment }}
on:
workflow_dispatch:
@ -53,7 +53,7 @@ jobs:
sudo apt-get update
sudo apt-get install cf8-cli
cf api api.fr.cloud.gov
cf auth "$CF_USERNAME" "$CF_PASSWORD"
cf auth "$cf_username" "$cf_password"
cf target -o cisa-dotgov -s $DESTINATION_ENVIRONMENT

View file

@ -0,0 +1,102 @@
# 26. Django Waffle library for Feature Flags
Date: 2024-05-22 (back dated)
## Status
Approved
## Context
When we decided to implement server-side rendering ([ADR#8 - Server-Side rendering](./0008-server-side-rendering.md)), we identified a potential risk: users and stakeholders might expect increasingly interactive experiences similar to those found in single-page applications (SPAs). Modern JavaScript frameworks such as React, Angular, and Vue enable rich interactivity by allowing applications to update portions of the page dynamically—often without requiring a full-page reload. These frameworks abstract AJAX and DOM manipulation, creating a high-level interface between JavaScript, HTML, and the browser.
Our decision to use Django for server-rendered pages allowed us to deliver an MVP quickly and facilitated easy onboarding for new developers. However, the anticipated risk materialized, and stakeholders now expect a more seamless, SPA-like experience.
We already leverage vanilla JavaScript for interactive components throughout the application. These implementations are neatly contained within Immediately Invoked Function Expressions (IIFEs) and are designed to extend specific components without interfering with Djangos server-rendered structure.
However, new components that require features like pagination, search, and filtering demand a more responsive, real-time user experience. This prompted an exploration of possible solutions.
## Considered Options
**Option 1:** Migrate to a Full SPA with Django as a Backend API
This approach involves refactoring Django into a backend-only service and adopting a modern JavaScript framework for the frontend.
✅ Pros:
- Future-proof solution that aligns with modern web development practices.
- Enables highly interactive and dynamic UI.
- Clean separation of concerns between frontend and backend.
❌ Cons:
- Requires significant investment in development and infrastructure changes.
- Major refactoring effort, delaying feature delivery.
- Increased complexity for testing and deployment.
This approach was deemed too costly in terms of both time and resources.
---
**Option 2:** Adopt a Modern JS Framework for Select Parts of the Application
Instead of a full migration, this approach involves integrating a modern JavaScript framework (e.g., React or Vue) only in areas that require high interactivity.
✅ Pros:
- Avoids a complete rewrite, allowing incremental improvements.
- More flexibility in choosing the level of interactivity per feature.
❌ Cons:
- Introduces multiple frontend paradigms, increasing developer onboarding complexity.
- Requires new deployment and build infrastructure.
- Creates long-term technical debt if legacy Django templates and new JS-driven components coexist indefinitely.
This approach would still introduce diverging implementation stacks, leading to long-term maintenance challenges.
---
**Option 3:** Use a Lightweight JavaScript Framework (e.g., HTMX, HTMZ)
Instead of React or Vue, this approach involves using a minimal JavaScript framework like HTMX or HTMZ to enhance interactivity while preserving Djangos server-rendered structure.
✅ Pros:
- Reduces the need for a full rewrite.
- Keeps Django templates largely intact.
- Minimizes complexity compared to React or Vue.
❌ Cons:
- Limited community support and long-term viability concerns.
- Still introduces new technology and learning curves.
- Unclear whether it fully meets our interactivity needs.
Ultimately, we determined that the benefits did not outweigh the potential downsides.
---
**Option 4:** Extend Vanilla JavaScript with AJAX (Selected Option)
This approach involves incrementally enhancing Djangos server-rendered pages with AJAX while maintaining our existing architecture.
✅ Pros:
Avoids expensive refactors and new dependencies.
- Fully customized to our existing codebase.
- Keeps Django templates intact while allowing dynamic updates.
- No need for additional build tools or frontend frameworks.
❌ Cons:
- Requires designing our own structured approach to AJAX.
- Testing and maintainability must be carefully considered.
This approach aligns with our existing architecture and skill set while meeting stakeholder demands for interactivity.
## Decision
We chose Option 4: Extending our use of vanilla JavaScript with AJAX.
## Consequences
1. Ownership of Solution
- We fully control the implementation without external dependencies.
2. Maintainability
- Our AJAX implementation will follow an object-oriented approach, with a base class for components requiring pagination, search, and filtering.
3. Backend Considerations
- Views handling AJAX responses will be explicitly designated as JSON views.
4. Scalability
- While this approach works now, we may need to reassess in the future if interactivity demands continue to grow.
This decision allows us to enhance the application's responsiveness without disrupting existing architecture or delaying feature development.

1030
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@
"license": "ISC",
"dependencies": {
"@uswds/uswds": "3.8.1",
"pa11y-ci": "^3.0.1",
"pa11y-ci": "^3.1.0",
"sass": "^1.54.8"
},
"devDependencies": {

View file

@ -11,6 +11,7 @@ from django.db.models import (
Value,
When,
)
from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from registrar.models.federal_agency import FederalAgency
@ -24,7 +25,7 @@ from registrar.utility.admin_helpers import (
from django.conf import settings
from django.contrib.messages import get_messages
from django.contrib.admin.helpers import AdminForm
from django.shortcuts import redirect
from django.shortcuts import redirect, get_object_or_404
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@ -162,6 +163,18 @@ class MyUserAdminForm(UserChangeForm):
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
}
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def __init__(self, *args, **kwargs):
"""Custom init to modify the user form"""
super(MyUserAdminForm, self).__init__(*args, **kwargs)
@ -522,6 +535,18 @@ class CustomLogEntryAdmin(LogEntryAdmin):
"user_url",
]
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
# We name the custom prop 'resource' because linter
# is not allowing a short_description attr on it
# This gets around the linter limitation, for now.
@ -541,13 +566,6 @@ class CustomLogEntryAdmin(LogEntryAdmin):
change_form_template = "admin/change_form_no_submit.html"
add_form_template = "admin/change_form_no_submit.html"
# Select log entry to change -> Log entries
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Log entries"
return super().changelist_view(request, extra_context=extra_context)
# #786: Skipping on updating audit log tab titles for now
# def change_view(self, request, object_id, form_url="", extra_context=None):
# if extra_context is None:
@ -628,6 +646,18 @@ class AdminSortFields:
class AuditedAdmin(admin.ModelAdmin):
"""Custom admin to make auditing easier."""
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def history_view(self, request, object_id, extra_context=None):
"""On clicking 'History', take admin to the auditlog view for an object."""
return HttpResponseRedirect(
@ -1028,6 +1058,18 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
extra_context = {"domain_requests": domain_requests, "domains": domains, "portfolios": portfolios}
return super().change_view(request, object_id, form_url, extra_context)
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class HostIPInline(admin.StackedInline):
"""Edit an ip address on the host page."""
@ -1052,14 +1094,6 @@ class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin):
search_help_text = "Search by domain or host name."
inlines = [HostIPInline]
# Select host to change -> Host
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Host"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class HostIpResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -1075,14 +1109,6 @@ class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin):
resource_classes = [HostIpResource]
model = models.HostIP
# Select host ip to change -> Host ip
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Host IP"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class ContactResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -1204,14 +1230,6 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().change_view(request, object_id, form_url, extra_context=extra_context)
# Select contact to change -> Contacts
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Contacts"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def save_model(self, request, obj, form, change):
# Clear warning messages before saving
storage = messages.get_messages(request)
@ -1326,6 +1344,7 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
search_help_text = "Search by first name, last name, email, or portfolio."
change_form_template = "django/admin/user_portfolio_permission_change_form.html"
delete_confirmation_template = "django/admin/user_portfolio_permission_delete_confirmation.html"
def get_roles(self, obj):
readable_roles = obj.get_readable_roles()
@ -1525,13 +1544,26 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
# Override for the delete confirmation page on the domain table (bulk delete action)
delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html"
# Select domain invitations to change -> Domain invitations
def changelist_view(self, request, extra_context=None):
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Override the change_view to add the invitation obj for the change_form_object_tools template"""
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Domain invitations"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
# Get the domain invitation object
invitation = get_object_or_404(DomainInvitation, id=object_id)
extra_context["invitation"] = invitation
if request.method == "POST" and "cancel_invitation" in request.POST:
if invitation.status == DomainInvitation.DomainInvitationStatus.INVITED:
invitation.cancel_invitation()
invitation.save(update_fields=["status"])
messages.success(request, _("Invitation canceled successfully."))
# Redirect back to the change view
return redirect(reverse("admin:registrar_domaininvitation_change", args=[object_id]))
return super().change_view(request, object_id, form_url, extra_context)
def delete_view(self, request, object_id, extra_context=None):
"""
@ -1551,6 +1583,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
which will be successful if a single User exists for that email; otherwise, will
just continue to create the invitation.
"""
if not change:
domain = obj.domain
domain_org = getattr(domain.domain_info, "portfolio", None)
@ -1647,14 +1680,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
autocomplete_fields = ["portfolio"]
change_form_template = "django/admin/portfolio_invitation_change_form.html"
# Select portfolio invitations to change -> Portfolio invitations
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Portfolio invitations"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.html"
def save_model(self, request, obj, form, change):
"""
@ -2045,14 +2071,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
readonly_fields.extend([field for field in self.analyst_readonly_fields])
return readonly_fields # Read-only fields for analysts
# Select domain information to change -> Domain information
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Domain information"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Customize the behavior of formfields with foreign key relationships. This will customize
the behavior of selects. Customized behavior includes sorting of objects in list."""
@ -2264,11 +2282,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
@admin.display(description=_("Requested Domain"))
def custom_requested_domain(self, obj):
# Example: Show different icons based on `status`
url = reverse("admin:registrar_domainrequest_changelist") + f"{obj.id}"
text = obj.requested_domain
if obj.portfolio:
return format_html('<a href="{}"><img src="/public/admin/img/icon-yes.svg"> {}</a>', url, text)
return format_html('<a href="{}">{}</a>', url, text)
return format_html(
f'<img class="padding-right-05" src="/public/admin/img/icon-yes.svg" aria-hidden="true">{escape(text)}'
)
return text
custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore
@ -2872,11 +2891,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if next_char.isdigit():
should_apply_default_filter = True
# Select domain request to change -> Domain requests
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Domain requests"
if should_apply_default_filter:
# modify the GET of the request to set the selected filter
modified_get = copy.deepcopy(request.GET)
@ -3715,11 +3729,13 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Using variables to get past the linter
message1 = f"Cannot delete Domain when in state {obj.state}"
message2 = f"This subdomain is being used as a hostname on another domain: {err.note}"
message3 = f"Command failed with note: {err.note}"
# Human-readable mappings of ErrorCodes. Can be expanded.
error_messages = {
# noqa on these items as black wants to reformat to an invalid length
ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION: message1,
ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: message2,
ErrorCode.COMMAND_FAILED: message3,
}
message = "Cannot connect to the registry"
@ -3931,14 +3947,6 @@ class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# If no redirection is needed, return the original response
return response
# Select draft domain to change -> Draft domains
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Draft domains"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class PublicContactResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -4360,14 +4368,6 @@ class UserGroupAdmin(AuditedAdmin):
def user_group(self, obj):
return obj.name
# Select user groups to change -> User groups
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "User groups"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class WaffleFlagAdmin(FlagAdmin):
"""Custom admin implementation of django-waffle's Flag class"""
@ -4384,6 +4384,13 @@ class WaffleFlagAdmin(FlagAdmin):
if extra_context is None:
extra_context = {}
extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag")
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select waffle flags to change", display "Waffle Flags")
# see "base_site.html" for the <title> code.
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
return super().changelist_view(request, extra_context=extra_context)

View file

@ -1,130 +0,0 @@
/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button,
* attach the seleted start and end dates to a url that'll trigger the view, and finally
* redirect to that url.
*
* This function also sets the start and end dates to match the url params if they exist
*/
(function () {
// Function to get URL parameter value by name
function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
// Get the current date in the format YYYY-MM-DD
let currentDate = new Date().toISOString().split('T')[0];
// Default the value of the start date input field to the current date
let startDateInput = document.getElementById('start');
// Default the value of the end date input field to the current date
let endDateInput = document.getElementById('end');
let exportButtons = document.querySelectorAll('.exportLink');
if (exportButtons.length > 0) {
// Check if start and end dates are present in the URL
let urlStartDate = getParameterByName('start_date');
let urlEndDate = getParameterByName('end_date');
// Set input values based on URL parameters or current date
startDateInput.value = urlStartDate || currentDate;
endDateInput.value = urlEndDate || currentDate;
exportButtons.forEach((btn) => {
btn.addEventListener('click', function () {
// Get the selected start and end dates
let startDate = startDateInput.value;
let endDate = endDateInput.value;
let exportUrl = btn.dataset.exportUrl;
// Build the URL with parameters
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
// Redirect to the export URL
window.location.href = exportUrl;
});
});
}
})();
/** An IIFE to initialize the analytics page
*/
(function () {
function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
var canvas = document.getElementById(canvasId);
if (!canvas) {
return
}
var ctx = canvas.getContext("2d");
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
var data = {
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
datasets: [
{
label: labelOne,
backgroundColor: "rgba(255, 99, 132, 0.2)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
data: listOne,
},
{
label: labelTwo,
backgroundColor: "rgba(75, 192, 192, 0.2)",
borderColor: "rgba(75, 192, 192, 1)",
borderWidth: 1,
data: listTwo,
},
],
};
var options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: title
}
},
scales: {
y: {
beginAtZero: true,
},
},
};
new Chart(ctx, {
type: "bar",
data: data,
options: options,
});
}
function initComparativeColumnCharts() {
document.addEventListener("DOMContentLoaded", function () {
createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date");
createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date");
createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date");
createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date");
createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date");
createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date");
});
};
initComparativeColumnCharts();
})();

View file

@ -5695,19 +5695,35 @@ const createHeaderButton = (header, headerName) => {
buttonEl.setAttribute("tabindex", "0");
buttonEl.classList.add(SORT_BUTTON_CLASS);
// ICON_SOURCE
// ---- END DOTGOV EDIT
// Change icons on sort, use source from arro_upward and arrow_downward
// buttonEl.innerHTML = Sanitizer.escapeHTML`
// <svg class="${PREFIX}-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
// <g class="descending" fill="transparent">
// <path d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" />
// </g>
// <g class="ascending" fill="transparent">
// <path transform="rotate(180, 12, 12)" d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" />
// </g>
// <g class="unsorted" fill="transparent">
// <polygon points="15.17 15 13 17.17 13 6.83 15.17 9 16.58 7.59 12 3 7.41 7.59 8.83 9 11 6.83 11 17.17 8.83 15 7.42 16.41 12 21 16.59 16.41 15.17 15"/>
// </g>
// </svg>
// `;
buttonEl.innerHTML = Sanitizer.escapeHTML`
<svg class="${PREFIX}-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g class="descending" fill="transparent">
<path d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" />
<path d="m20 12-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z"/>
</g>
<g class="ascending" fill="transparent">
<path transform="rotate(180, 12, 12)" d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" />
<path d="m4 12 1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z"/>
</g>
<g class="unsorted" fill="transparent">
<polygon points="15.17 15 13 17.17 13 6.83 15.17 9 16.58 7.59 12 3 7.41 7.59 8.83 9 11 6.83 11 17.17 8.83 15 7.42 16.41 12 21 16.59 16.41 15.17 15"/>
</g>
</svg>
`;
// ---- END DOTGOV EDIT
header.appendChild(buttonEl);
updateSortLabel(header, headerName);
};

View file

@ -0,0 +1,177 @@
import { debounce } from '../getgov/helpers.js';
import { getParameterByName } from './helpers-admin.js';
/** This function also sets the start and end dates to match the url params if they exist
*/
function initAnalyticsExportButtons() {
// Get the current date in the format YYYY-MM-DD
let currentDate = new Date().toISOString().split('T')[0];
// Default the value of the start date input field to the current date
let startDateInput = document.getElementById('start');
// Default the value of the end date input field to the current date
let endDateInput = document.getElementById('end');
let exportButtons = document.querySelectorAll('.exportLink');
if (exportButtons.length > 0) {
// Check if start and end dates are present in the URL
let urlStartDate = getParameterByName('start_date');
let urlEndDate = getParameterByName('end_date');
// Set input values based on URL parameters or current date
startDateInput.value = urlStartDate || currentDate;
endDateInput.value = urlEndDate || currentDate;
exportButtons.forEach((btn) => {
btn.addEventListener('click', function () {
// Get the selected start and end dates
let startDate = startDateInput.value;
let endDate = endDateInput.value;
let exportUrl = btn.dataset.exportUrl;
// Build the URL with parameters
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
// Redirect to the export URL
window.location.href = exportUrl;
});
});
}
};
/**
* Creates a diagonal stripe pattern for chart.js
* Inspired by https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
* and https://github.com/ashiguruma/patternomaly
* @param {string} backgroundColor - Background color of the pattern
* @param {string} [lineColor="white"] - Color of the diagonal lines
* @param {boolean} [rightToLeft=false] - Direction of the diagonal lines
* @param {number} [lineGap=1] - Gap between lines
* @returns {CanvasPattern} A canvas pattern object for use with backgroundColor
*/
function createDiagonalPattern(backgroundColor, lineColor, rightToLeft=false, lineGap=1) {
// Define the canvas and the 2d context so we can draw on it
let shape = document.createElement("canvas");
shape.width = 20;
shape.height = 20;
let context = shape.getContext("2d");
// Fill with specified background color
context.fillStyle = backgroundColor;
context.fillRect(0, 0, shape.width, shape.height);
// Set stroke properties
context.strokeStyle = lineColor;
context.lineWidth = 2;
// Rotate canvas for a right-to-left pattern
if (rightToLeft) {
context.translate(shape.width, 0);
context.rotate(90 * Math.PI / 180);
};
// First diagonal line
let halfSize = shape.width / 2;
context.moveTo(halfSize - lineGap, -lineGap);
context.lineTo(shape.width + lineGap, halfSize + lineGap);
// Second diagonal line (x,y are swapped)
context.moveTo(-lineGap, halfSize - lineGap);
context.lineTo(halfSize + lineGap, shape.width + lineGap);
context.stroke();
return context.createPattern(shape, "repeat");
}
function createComparativeColumnChart(id, title, labelOne, labelTwo) {
var canvas = document.getElementById(id);
if (!canvas) {
return
}
var ctx = canvas.getContext("2d");
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
var data = {
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
datasets: [
{
label: labelOne,
backgroundColor: "rgba(255, 99, 132, 0.3)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
data: listOne,
// Set this line style to be rightToLeft for visual distinction
backgroundColor: createDiagonalPattern('rgba(255, 99, 132, 0.3)', 'white', true)
},
{
label: labelTwo,
backgroundColor: "rgba(75, 192, 192, 0.3)",
borderColor: "rgba(75, 192, 192, 1)",
borderWidth: 1,
data: listTwo,
backgroundColor: createDiagonalPattern('rgba(75, 192, 192, 0.3)', 'white')
},
],
};
var options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: title
}
},
scales: {
y: {
beginAtZero: true,
},
},
};
return new Chart(ctx, {
type: "bar",
data: data,
options: options,
});
}
/** An IIFE to initialize the analytics page
*/
export function initAnalyticsDashboard() {
const analyticsPageContainer = document.querySelector('.analytics-dashboard-charts');
if (analyticsPageContainer) {
document.addEventListener("DOMContentLoaded", function () {
initAnalyticsExportButtons();
// Create charts and store each instance of it
const chartInstances = new Map();
const charts = [
{ id: "managed-domains-chart", title: "Managed domains" },
{ id: "unmanaged-domains-chart", title: "Unmanaged domains" },
{ id: "deleted-domains-chart", title: "Deleted domains" },
{ id: "ready-domains-chart", title: "Ready domains" },
{ id: "submitted-requests-chart", title: "Submitted requests" },
{ id: "all-requests-chart", title: "All requests" }
];
charts.forEach(chart => {
if (chartInstances.has(chart.id)) chartInstances.get(chart.id).destroy();
chartInstances.set(chart.id, createComparativeColumnChart(chart.id, chart.title, "Start Date", "End Date"));
});
// Add resize listener to each chart
window.addEventListener("resize", debounce(() => {
chartInstances.forEach((chart) => {
if (chart?.canvas) chart.resize();
});
}, 200));
});
}
};

View file

@ -0,0 +1,15 @@
/**
* Initializes buttons to behave like links by navigating to their data-url attribute
* Example usage: <button class="use-button-as-link" data-url="/some/path">Click me</button>
*/
export function initButtonLinks() {
document.querySelectorAll('button.use-button-as-link').forEach(button => {
button.addEventListener('click', function() {
// Equivalent to button.getAttribute("data-href")
const href = this.dataset.href;
if (href) {
window.location.href = href;
}
});
});
}

View file

@ -22,3 +22,13 @@ export function addOrRemoveSessionBoolean(name, add){
sessionStorage.removeItem(name);
}
}
export function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}

View file

@ -15,6 +15,8 @@ import { initDomainFormTargetBlankButtons } from './domain-form.js';
import { initDynamicPortfolioFields } from './portfolio-form.js';
import { initDynamicDomainInformationFields } from './domain-information-form.js';
import { initDynamicDomainFields } from './domain-form.js';
import { initAnalyticsDashboard } from './analytics.js';
import { initButtonLinks } from './button-utils.js';
// General
initModals();
@ -22,6 +24,7 @@ initCopyToClipboard();
initFilterHorizontalWidget();
initDescriptions();
initSubmitBar();
initButtonLinks();
// Domain request
initIneligibleModal();
@ -41,3 +44,6 @@ initDynamicPortfolioFields();
// Domain information
initDynamicDomainInformationFields();
// Analytics dashboard
initAnalyticsDashboard();

View file

@ -2,11 +2,41 @@ import { submitForm } from './helpers.js';
export function initDomainRequestForm() {
document.addEventListener('DOMContentLoaded', function() {
// These are the request steps in DomainRequestWizard, such as current_websites or review
initRequestStepCurrentWebsitesListener();
initRequestStepReviewListener();
});
}
function initRequestStepReviewListener() {
const button = document.getElementById("domain-request-form-submit-button");
if (button) {
button.addEventListener("click", function () {
submitForm("submit-domain-request-form");
});
}
});
}
function initRequestStepCurrentWebsitesListener() {
//register-form-step
const addAnotherSiteButton = document.getElementById("submit-domain-request--site-button");
if (addAnotherSiteButton) {
// Check for focus state in sessionStorage
const focusTarget = sessionStorage.getItem("lastFocusedElement");
if (focusTarget) {
document.querySelector(focusTarget)?.focus();
}
// Add form submit handler to store focus state
const form = document.querySelector("form");
if (form) {
form.addEventListener("submit", () => {
const activeElement = document.activeElement;
if (activeElement) {
sessionStorage.setItem("lastFocusedElement", "#" + activeElement.id);
}
});
}
// We only want to do this action once, so we clear out the session
sessionStorage.removeItem("lastFocusedElement");
}
}

View file

@ -96,3 +96,14 @@ export function submitForm(form_id) {
console.error("Form '" + form_id + "' not found.");
}
}
/**
* Helper function to strip HTML tags
* THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS
*/
export function unsafeStripHtmlTags(input) {
const tempDiv = document.createElement("div");
// NOTE: THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS
tempDiv.innerHTML = input;
return tempDiv.textContent || tempDiv.innerText || "";
}

View file

@ -18,7 +18,7 @@ export function initPortfolioNewMemberPageToggle() {
const unique_id = `${member_type}-${member_id}`;
let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member";
wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`);
wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`, "usa-icon--large");
// This easter egg is only for fixtures that dont have names as we are displaying their emails
// All prod users will have emails linked to their account
@ -100,8 +100,8 @@ export function initAddNewMemberPageListeners() {
const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`);
permissionSections.forEach(section => {
// Find the <h3> element text
const sectionTitle = section.textContent;
// Find the <h3> element text, strip out the '*'
const sectionTitle = section.textContent.trim().replace(/\*$/, "") + ": ";
// Find the associated radio buttons container (next fieldset)
const fieldset = section.nextElementSibling;
@ -128,25 +128,29 @@ export function initAddNewMemberPageListeners() {
});
} else {
// for admin users, the permissions are always the same
appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer);
appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer);
appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer);
appendPermissionInContainer('Domains: ', 'Viewer', permissionDetailsContainer);
appendPermissionInContainer('Domain requests: ', 'Creator', permissionDetailsContainer);
appendPermissionInContainer('Members: ', 'Manager', permissionDetailsContainer);
}
}
function appendPermissionInContainer(sectionTitle, permissionDisplay, permissionContainer) {
// Create new elements for the content
const titleElement = document.createElement("h4");
titleElement.textContent = sectionTitle;
titleElement.classList.add("text-primary", "margin-bottom-0");
const elementContainer = document.createElement("p");
elementContainer.classList.add("margin-top-0", "margin-bottom-1");
const permissionElement = document.createElement("p");
const titleElement = document.createElement("strong");
titleElement.textContent = sectionTitle;
titleElement.classList.add("text-primary-darker");
const permissionElement = document.createElement("span");
permissionElement.textContent = permissionDisplay;
permissionElement.classList.add("margin-top-0");
// Append to the content container
permissionContainer.appendChild(titleElement);
permissionContainer.appendChild(permissionElement);
elementContainer.appendChild(titleElement);
elementContainer.appendChild(permissionElement);
permissionContainer.appendChild(elementContainer);
}
/*

View file

@ -79,13 +79,13 @@ export function addModal(id, ariaLabelledby, ariaDescribedby, modalHeading, moda
* @param {string} modal_button_text - The action button's text
* @param {string} screen_reader_text - A screen reader helper
*/
export function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text) {
export function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text, icon_class) {
const generateModalButton = (mobileOnly = false) => `
<a
role="button"
id="button-trigger-${action}-${unique_id}"
href="#toggle-${action}-${unique_id}"
class="usa-button usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary ${mobileOnly ? 'visible-mobile-flex' : ''}"
class="usa-button usa-button--unstyled text-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary ${mobileOnly ? 'visible-mobile-flex' : ''}"
aria-controls="toggle-${action}-${unique_id}"
data-open-modal
>
@ -99,7 +99,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
// Main kebab structure
const kebab = `
${generateModalButton(true)} <!-- Mobile button -->
<div class="usa-accordion usa-accordion--more-actions margin-right-2 hidden-mobile-flex">
<div class="usa-accordion usa-accordion--more-actions margin-right-2 margin-top-3px hidden-mobile-flex">
<div class="usa-accordion__heading">
<button
type="button"
@ -108,12 +108,12 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
aria-controls="more-actions-${unique_id}"
aria-label="${screen_reader_text}"
>
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<svg class="usa-icon${icon_class ? " " + icon_class : ""}" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
</svg>
</button>
</div>
<div id="more-actions-${unique_id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-neg-1" hidden>
<div id="more-actions-${unique_id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-0${icon_class === 'usa-icon--large' ? ' top-28px' : ''}" hidden>
<h2>More options</h2>
${generateModalButton()} <!-- Desktop button -->
</div>

View file

@ -1,4 +1,4 @@
import { hideElement, showElement, getCsrfToken } from './helpers.js';
import { hideElement, showElement, getCsrfToken, unsafeStripHtmlTags } from './helpers.js';
import { uswdsInitializeModals, uswdsUnloadModals } from './helpers-uswds.js';
import { BaseTable, addModal, generateKebabHTML } from './table-base.js';
@ -98,9 +98,10 @@ export class DomainRequestsTable extends BaseTable {
// Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly
if (this.portfolioValue) {
// NOTE: THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS
const sanitizedDomainName = unsafeStripHtmlTags(domainName);
// 2nd path (org model): Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users
modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', domainName);
modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', sanitizedDomainName);
}
}
@ -117,7 +118,7 @@ export class DomainRequestsTable extends BaseTable {
${request.status}
</td>
<td class="width--action-column">
<div class="tablet:display-flex tablet:flex-row flex-wrap">
<div class="tablet:display-flex tablet:flex-row">
<a href="${actionUrl}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${request.svg_icon}"></use>

View file

@ -24,9 +24,12 @@ export class EditMemberDomainsTable extends BaseTable {
this.reviewButton = document.getElementById('review-domain-assignments');
this.backButton = document.getElementById('back-to-edit-domain-assignments');
this.saveButton = document.getElementById('save-domain-assignments');
this.initializeDomainAssignments();
}
async init() {
await this.initializeDomainAssignments();
this.initCancelEditDomainAssignmentButton();
this.initEventListeners();
return this;
}
getBaseUrl() {
return document.getElementById("get_member_domains_json_url");
@ -134,27 +137,33 @@ export class EditMemberDomainsTable extends BaseTable {
* member. It populates both initialDomainAssignments and initialDomainAssignmentsOnlyMember.
* It is called once per page load, but not called with subsequent table changes.
*/
initializeDomainAssignments() {
async initializeDomainAssignments() {
const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null;
if (!baseUrlValue) return;
if (!baseUrlValue) {
console.error("Base URL not found");
return;
}
try {
let searchParams = this.getDomainAssignmentSearchParams(this.portfolioValue);
let url = baseUrlValue + "?" + searchParams.toString();
fetch(url)
.then(response => response.json())
.then(data => {
let response = await fetch(url);
let data = await response.json();
if (data.error) {
console.error('Error in AJAX call: ' + data.error);
console.error("Error in AJAX call:", data.error);
return;
}
let dataObjects = this.getDataObjects(data);
// Map the id attributes of dataObjects to this.initialDomainAssignments
this.initialDomainAssignments = dataObjects.map(obj => obj.id);
this.initialDomainAssignmentsOnlyMember = dataObjects
.filter(obj => obj.member_is_only_manager)
.map(obj => obj.id);
})
.catch(error => console.error('Error fetching domain assignments:', error));
} catch (error) {
console.error("Error fetching domain assignments:", error);
}
}
/**
* Initializes listeners on checkboxes in the table. Checkbox listeners are used
@ -232,8 +241,6 @@ export class EditMemberDomainsTable extends BaseTable {
}
updateReadonlyDisplay() {
let totalAssignedDomains = this.getCheckedDomains().length;
// Create unassigned domains list
const unassignedDomainsList = document.createElement('ul');
unassignedDomainsList.classList.add('usa-list', 'usa-list--unstyled');
@ -260,35 +267,30 @@ export class EditMemberDomainsTable extends BaseTable {
// Clear existing content
domainAssignmentSummary.innerHTML = '';
// Append unassigned domains section
if (this.removedDomains.length) {
const unassignedHeader = document.createElement('h3');
unassignedHeader.classList.add('margin-bottom-05', 'h4');
unassignedHeader.textContent = 'Unassigned domains';
domainAssignmentSummary.appendChild(unassignedHeader);
domainAssignmentSummary.appendChild(unassignedDomainsList);
}
// Append assigned domains section
if (this.addedDomains.length) {
const assignedHeader = document.createElement('h3');
// Make this h3 look like a h4
assignedHeader.classList.add('margin-bottom-05', 'h4');
assignedHeader.textContent = 'Assigned domains';
assignedHeader.textContent = `New assignments (${this.addedDomains.length})`;
domainAssignmentSummary.appendChild(assignedHeader);
domainAssignmentSummary.appendChild(assignedDomainsList);
}
// Append total assigned domains section
const totalHeader = document.createElement('h3');
// Make this h3 look like a h4
totalHeader.classList.add('margin-bottom-05', 'h4');
totalHeader.textContent = 'Total assigned domains';
domainAssignmentSummary.appendChild(totalHeader);
const totalCount = document.createElement('p');
totalCount.classList.add('margin-y-0');
totalCount.textContent = totalAssignedDomains;
domainAssignmentSummary.appendChild(totalCount);
// Append unassigned domains section
if (this.removedDomains.length) {
const unassignedHeader = document.createElement('h3');
unassignedHeader.classList.add('margin-bottom-05', 'h4');
unassignedHeader.textContent =`Removed assignments (${this.removedDomains.length})`;
domainAssignmentSummary.appendChild(unassignedHeader);
domainAssignmentSummary.appendChild(unassignedDomainsList);
}
if (!this.addedDomains.length && !this.removedDomains.length) {
const noChangesParagraph = document.createElement('p');
noChangesParagraph.textContent = "No changes were detected. Click the “Back” button to edit this members domain assignments.";
domainAssignmentSummary.appendChild(noChangesParagraph);
}
}
showReadonlyMode() {
@ -355,14 +357,14 @@ export class EditMemberDomainsTable extends BaseTable {
}
export function initEditMemberDomainsTable() {
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
const isEditMemberDomainsPage = document.getElementById("edit-member-domains");
if (isEditMemberDomainsPage) {
const editMemberDomainsTable = new EditMemberDomainsTable();
if (!isEditMemberDomainsPage) return; // Exit if not on the right page
const editMemberDomainsTable = await new EditMemberDomainsTable().init();
if (editMemberDomainsTable.tableWrapper) {
// Initial load
editMemberDomainsTable.loadTable(1);
}
editMemberDomainsTable.loadTable(1); // Initial load
}
});
}

View file

@ -69,13 +69,14 @@ export class MembersTable extends BaseTable {
const kebabHTML = customTableOptions.hasAdditionalActions ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `Expand for more options for ${member.name}`): '';
const row = document.createElement('tr');
row.classList.add('hide-td-borders');
let admin_tagHTML = ``;
if (member.is_admin)
admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary-dark text-semibold">Admin</span>`
// generate html blocks for domains and permissions for the member
let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url);
let permissionsHTML = this.generatePermissionsHTML(member.permissions, customTableOptions.UserPortfolioPermissionChoices);
let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url, unique_id);
let permissionsHTML = this.generatePermissionsHTML(member.is_admin, member.permissions, customTableOptions.UserPortfolioPermissionChoices, unique_id);
// domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand
let showMoreButton = '';
@ -96,28 +97,34 @@ export class MembersTable extends BaseTable {
</button>
`;
showMoreRow.innerHTML = `<td colspan='3' headers="header-member row-header-${unique_id}" class="padding-top-0"><div class='grid-row'>${domainsHTML} ${permissionsHTML}</div></td>`;
showMoreRow.classList.add('show-more-content');
showMoreRow.classList.add('display-none');
showMoreRow.innerHTML = `
<td colspan='4' headers="header-member row-header-${unique_id}" class="padding-top-0">
${showMoreButton}
<div class='grid-row grid-gap-2 show-more-content display-none'>
${domainsHTML}
${permissionsHTML}
</div>
</td>
`;
showMoreRow.id = unique_id;
}
row.innerHTML = `
<th role="rowheader" headers="header-member" data-label="member email" id='row-header-${unique_id}'>
${member.member_display} ${admin_tagHTML} ${showMoreButton}
<th class="padding-bottom-0" role="rowheader" headers="header-member" data-label="Member" id='row-header-${unique_id}'>
${member.member_display} ${admin_tagHTML}
</th>
<td headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="last_active">
<td class="padding-bottom-0" headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="Last active">
${last_active.display_value}
</td>
<td headers="header-action row-header-${unique_id}" class="width--action-column">
<td class="padding-bottom-0" headers="header-action row-header-${unique_id}" class="width--action-column">
<div class="tablet:display-flex tablet:flex-row flex-align-center">
<a href="${member.action_url}">
<a href="${member.action_url}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use>
</svg>
${member.action_label} <span class="usa-sr-only">${member.name}</span>
</a>
<span class="padding-left-1">${customTableOptions.hasAdditionalActions ? kebabHTML : ''}</span>
${customTableOptions.hasAdditionalActions ? kebabHTML : ''}
</div>
</td>
`;
@ -146,16 +153,15 @@ export class MembersTable extends BaseTable {
*
* @param {HTMLElement} toggleButton - The button that toggles the content visibility.
* @param {HTMLElement} contentDiv - The content div whose visibility is toggled.
* @param {HTMLElement} buttonParentRow - The parent row element containing the button.
*/
function toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow) {
function toggleShowMoreButton(toggleButton, contentDiv) {
const spanElement = toggleButton.querySelector('span');
const useElement = toggleButton.querySelector('use');
if (contentDiv.classList.contains('display-none')) {
showElement(contentDiv);
spanElement.textContent = 'Close';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
buttonParentRow.classList.add('hide-td-borders');
toggleButton.classList.add('margin-bottom-2');
let ariaLabelText = "Close additional information";
let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder");
@ -169,7 +175,7 @@ export class MembersTable extends BaseTable {
hideElement(contentDiv);
spanElement.textContent = 'Expand';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
buttonParentRow.classList.remove('hide-td-borders');
toggleButton.classList.remove('margin-bottom-2');
let ariaLabelText = "Expand for additional information";
let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder");
@ -182,14 +188,11 @@ export class MembersTable extends BaseTable {
let toggleButtons = document.querySelectorAll('.usa-button--show-more-button');
toggleButtons.forEach((toggleButton) => {
// get contentDiv for element specified in data-for attribute of toggleButton
let dataFor = toggleButton.dataset.for;
let contentDiv = document.getElementById(dataFor);
let buttonParentRow = toggleButton.parentElement.parentElement;
if (contentDiv && contentDiv.tagName.toLowerCase() === 'tr' && contentDiv.classList.contains('show-more-content') && buttonParentRow && buttonParentRow.tagName.toLowerCase() === 'tr') {
let contentDiv = buttonParentRow.querySelector(".show-more-content");
if (contentDiv && buttonParentRow && buttonParentRow.tagName.toLowerCase() === 'tr') {
toggleButton.addEventListener('click', function() {
toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow);
toggleShowMoreButton(toggleButton, contentDiv);
});
} else {
console.warn('Found a toggle button with no associated toggleable content or parent row');
@ -240,17 +243,21 @@ export class MembersTable extends BaseTable {
* @param {number} num_domains - The number of domains the member is assigned to.
* @param {Array} domain_names - An array of domain names.
* @param {Array} domain_urls - An array of corresponding domain URLs.
* @param {Array} unique_id - A unique row id.
* @returns {string} - A string of HTML displaying the domains assigned to the member.
*/
generateDomainsHTML(num_domains, domain_names, domain_urls, action_url) {
generateDomainsHTML(num_domains, domain_names, domain_urls, action_url, unique_id) {
// Initialize an empty string for the HTML
let domainsHTML = '';
// Only generate HTML if the member has one or more assigned domains
domainsHTML += "<div class='desktop:grid-col-4 margin-bottom-2 desktop:margin-bottom-0'>";
domainsHTML += `<h4 id='domains-assigned--heading-${unique_id}' class='font-body-xs margin-y-0'>Domains assigned</h4>`;
domainsHTML += `<section aria-labelledby='domains-assigned--heading-${unique_id}' tabindex='0'>`
if (num_domains > 0) {
domainsHTML += "<div class='desktop:grid-col-5 margin-bottom-2 desktop:margin-bottom-0'>";
domainsHTML += "<h4 class='font-body-xs margin-y-0'>Domains assigned</h4>";
domainsHTML += `<p class='font-body-xs text-base-dark margin-y-0'>This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:</p>`;
domainsHTML += `<p class='font-body-xs text-base-darker margin-y-0'>This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:</p>`;
if (num_domains > 1) {
domainsHTML += "<ul class='usa-list usa-list--unstyled margin-y-0'>";
// Display up to 6 domains with their URLs
@ -259,12 +266,18 @@ export class MembersTable extends BaseTable {
}
domainsHTML += "</ul>";
} else {
// We don't display this in a list for better screenreader support, when only one item exists.
domainsHTML += `<a class="font-body-xs" href="${domain_urls[0]}">${domain_names[0]}</a>`;
}
} else {
domainsHTML += `<p class='font-body-xs text-base-darker margin-y-0'>This member is assigned to 0 domains.</p>`;
}
// If there are more than 6 domains, display a "View assigned domains" link
domainsHTML += `<p class="font-body-xs"><a href="${action_url}/domains">View assigned domains</a></p>`;
domainsHTML += `<p class="font-body-xs"><a href="${action_url}/domains">View domain assignments</a></p>`;
domainsHTML += "</section>"
domainsHTML += "</div>";
}
return domainsHTML;
}
@ -362,7 +375,7 @@ export class MembersTable extends BaseTable {
* - VIEW_ALL_REQUESTS
* - EDIT_MEMBERS
* - VIEW_MEMBERS
*
* @param {String} unique_id
* @returns {string} - A string of HTML representing the user's additional permissions.
* If the user has no specific permissions, it returns a default message
* indicating no additional permissions.
@ -377,40 +390,51 @@ export class MembersTable extends BaseTable {
* - If no relevant permissions are found, the function returns a message stating that the user has no additional permissions.
* - The resulting HTML always includes a header "Additional permissions for this member" and appends the relevant permission descriptions.
*/
generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) {
let permissionsHTML = '';
generatePermissionsHTML(is_admin, member_permissions, UserPortfolioPermissionChoices, unique_id) {
// 1. Role
const memberAccessValue = is_admin ? "Admin" : "Basic";
// Define shared classes across elements for easier refactoring
let sharedParagraphClasses = "font-body-xs text-base-dark margin-top-1 p--blockquote";
// Check domain-related permissions
// 2. Domain access
let domainValue = "No access";
if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domains:</strong> Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>`;
domainValue = "Viewer";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domains:</strong> Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>`;
domainValue = "Viewer, limited";
}
// Check request-related permissions
// 3. Request access
let requestValue = "No access";
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domain requests:</strong> Can view all organization domain requests. Can create domain requests and modify their own requests.</p>`;
requestValue = "Creator";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domain requests (view-only):</strong> Can view all organization domain requests. Can't create or modify any domain requests.</p>`;
requestValue = "Viewer";
}
// Check member-related permissions
// 4. Member access
let memberValue = "No access";
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Members:</strong> Can manage members including inviting new members, removing current members, and assigning domains to members.</p>`;
memberValue = "Manager";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Members (view-only):</strong> Can view all organizational members. Can't manage any members.</p>`;
memberValue = "Viewer";
}
// If no specific permissions are assigned, display a message indicating no additional permissions
if (!permissionsHTML) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><b>No additional permissions:</b> There are no additional permissions for this member.</p>`;
}
// Add a permissions header and wrap the entire output in a container
permissionsHTML = `<div class='desktop:grid-col-7'><h4 class='font-body-xs margin-y-0'>Additional permissions for this member</h4>${permissionsHTML}</div>`;
// Helper function for faster element refactoring
const createPermissionItem = (label, value) => {
return `<p class="font-body-xs text-base-darker margin-top-1 p--blockquote">${label}: <strong>${value}</strong></p>`;
};
const permissionsHTML = `
<div class="desktop:grid-col-8">
<h4 id="member-access--heading-${unique_id}" class="font-body-xs margin-y-0">
Member access and permissions
</h4>
<section aria-labelledby="member-access--heading-${unique_id}" tabindex="0">
${createPermissionItem("Member access", memberAccessValue)}
${createPermissionItem("Domains", domainValue)}
${createPermissionItem("Domain requests", requestValue)}
${createPermissionItem("Members", memberValue)}
</section>
</div>
`;
return permissionsHTML;
}

View file

@ -5,17 +5,26 @@
display: inline-block;
width: auto;
position: relative;
.usa-accordion__button {
border-radius: units(.5);
}
.usa-accordion__button:focus {
outline-offset: 0;
outline-width: 3px;
}
.usa-accordion__button[aria-expanded=false],
.usa-accordion__button[aria-expanded=false]:hover,
.usa-accordion__button[aria-expanded=true],
.usa-accordion__button[aria-expanded=true]:hover {
background-image: none;
}
.usa-accordion__button[aria-expanded=true] {
background-color: color('primary-lighter');
}
.usa-accordion__content {
// Note, width is determined by a custom width class on one of the children
position: absolute;
z-index: 1;
left: 0;
border-radius: 4px;
border: solid 1px color('base-lighter');
padding: units(2) units(2) units(3) units(2);
@ -32,22 +41,37 @@
}
}
// This will work in responsive tables if we overwrite the overflow value on the table container
// Works with styles in _tables
@include at-media(desktop) {
.usa-accordion--more-actions .usa-accordion__content {
left: 0;
}
}
.usa-accordion--select .usa-accordion__content {
top: 33.88px;
}
.usa-accordion--more-actions .usa-accordion__content {
top: 30px;
// We need to match the height of the trigger button
// to align the 'popup' underneath
top: 20px;
&.top-28px {
top: 28px;
}
}
// Special positioning for the kabob menu popup in the last row on a given page
// This won't work on the Members table rows because that table has show-more rows
// Currently, that's not an issue since that Members table is not wrapped in the
// reponsive wrapper.
tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
@include at-media-max("desktop") {
tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
top: auto;
bottom: -10px;
right: 30px;
}
}
// A CSS only show-more/show-less based on usa-accordion

View file

@ -498,15 +498,36 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too
font-size: 13px;
}
.module--custom {
a {
font-size: 13px;
font-weight: 600;
border: solid 1px var(--darkened-bg);
background: var(--darkened-bg);
.object-tools li button, button.addlink {
font-family: Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif;
text-transform: none !important;
font-size: 14px !important;
display: block;
float: left;
padding: 3px 12px;
background: var(--object-tools-bg) !important;
color: var(--object-tools-fg);
font-weight: 400;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: 15px;
cursor: pointer;
border: none;
line-height: 20px;
&:focus, &:hover{
background: var(--object-tools-hover-bg) !important;
}
}
// Mimic the style for <a>
.object-tools > p > button.addlink {
background-image: url(../admin/img/tooltag-add.svg) !important;
background-repeat: no-repeat !important;
background-position: right 7px center !important;
padding-right: 25px;
}
.usa-modal--django-admin .usa-prose ul > li {
list-style-type: inherit;
// Styling based off of the <p> styling in django admin
@ -536,13 +557,18 @@ details.dja-detail-table {
background-color: transparent;
}
thead tr {
background-color: var(--darkened-bg);
}
td, th {
padding-left: 12px;
border: none
border: none;
background-color: var(--darkened-bg);
color: var(--body-quiet-color);
}
thead > tr > th {
border-radius: 4px;
border-top: none;
border-bottom: none;
}
@ -812,6 +838,17 @@ div.dja__model-description{
text-transform: capitalize;
}
.module caption {
// Match the old <h2> size for django admin
font-size: 0.8125rem;
}
// text-bold doesn't work here due to style overrides, unfortunately.
// This is a workaround.
caption.text-bold {
font-weight: font-weight('bold');
}
.wrapped-button-group {
// This button group has too many items
flex-wrap: wrap;
@ -924,3 +961,38 @@ ul.add-list-reset {
background-color: transparent !important;
}
}
@media (min-width: 1080px) {
.analytics-dashboard-charts {
// Desktop layout - charts in top row, details in bottom row
display: grid;
gap: 2rem;
// Equal columns each gets 1/2 of the space
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"chart1 chart2"
"details1 details2"
"chart3 chart4"
"details3 details4"
"chart5 chart6"
"details5 details6";
.chart-1 { grid-area: chart1; }
.chart-2 { grid-area: chart2; }
.chart-3 { grid-area: chart3; }
.chart-4 { grid-area: chart4; }
.chart-5 { grid-area: chart5; }
.chart-6 { grid-area: chart6; }
.details-1 { grid-area: details1; }
.details-2 { grid-area: details2; }
.details-3 { grid-area: details3; }
.details-4 { grid-area: details4; }
.details-5 { grid-area: details5; }
.details-6 { grid-area: details6; }
}
}
#result_list > tbody tr > th > a {
text-decoration: underline;
}

View file

@ -46,7 +46,6 @@ body {
background-color: color('gray-1');
}
.section-outlined {
background-color: color('white');
border: 1px solid color('base-lighter');
@ -227,11 +226,6 @@ abbr[title] {
}
}
// Boost this USWDS utility class for the accordions in the portfolio requests table
.left-auto {
left: auto!important;
}
.usa-banner__inner--widescreen {
max-width: $widescreen-max-width;
}
@ -276,6 +270,14 @@ abbr[title] {
width: 25%;
}
.margin-top-3px {
margin-top: 3px;
}
.top-28px {
top: 28px;
}
/*
NOTE: width: 3% basically forces a fit-content effect in the table.
Fit-content itself does not work.

View file

@ -41,13 +41,8 @@ th {
}
}
// The member table has an extra "expand" row, which looks like a single row.
// But the DOM disagrees - so we basically need to hide the border on both rows.
#members__table-wrapper .dotgov-table tr:nth-last-child(2) td,
#members__table-wrapper .dotgov-table tr:nth-last-child(2) th {
border-bottom: none;
}
// .dotgov-table allows us to customize .usa-table on the user-facing pages,
// while leaving the default styles for use on the admin pages
.dotgov-table {
width: 100%;
@ -68,7 +63,8 @@ th {
border-bottom: 1px solid color('base-lighter');
}
thead th {
thead th,
thead th[aria-sort] {
color: color('primary-darker');
border-bottom: 2px solid color('base-light');
}
@ -93,19 +89,48 @@ th {
}
}
// Sortable headers
th[data-sortable][aria-sort=ascending],
th[data-sortable][aria-sort=descending] {
background-color: transparent;
.usa-table__header__button {
background-color: color('accent-cool-lightest');
border-radius: units(.5);
color: color('primary-darker');
&:hover {
background-color: color('accent-cool-lightest');
}
}
}
@include at-media(tablet-lg) {
th[data-sortable] .usa-table__header__button {
th[data-sortable]:not(.left-align-sort-button) .usa-table__header__button {
// position next to the copy
right: auto;
// slide left to mock a margin between the copy and the icon
transform: translateX(units(1));
// fix vertical alignment
top: units(1.5);
}
th[data-sortable].left-align-sort-button .usa-table__header__button {
left: 0;
}
}
&[aria-sort=ascending],
&[aria-sort=descending],
&:not([aria-sort]) {
right: auto;
}
}
// Currently the 'flash' when sort is clicked,
// this will become persistent if the double-sort bug is fixed
td[data-sort-active],
th[data-sort-active] {
background-color: color('primary-lightest');
}
}
// The member table has an extra "expand" row, which looks like a single row.
// But the DOM disagrees - so we basically need to hide the border on both rows.
#members__table-wrapper .dotgov-table tr:nth-last-child(2) td,
#members__table-wrapper .dotgov-table tr:nth-last-child(2) th {
border-bottom: none;
}
.dotgov-table--cell-padding-2 {
td, th {
padding: units(2);
@ -118,7 +143,7 @@ th {
}
.usa-table--bg-transparent {
td, thead th {
td, th, thead th {
background-color: transparent;
}
}
@ -127,3 +152,12 @@ th {
.usa-table--full-borderless th {
border: none !important;
}
// This is an override to overflow on certain tables (note the custom class)
// so that a popup menu can appear and starddle the edge of the table on large
// screen sizes. Works with styles in _accordions
@include at-media(desktop) {
.usa-table-container--scrollable.usa-table-container--override-overflow {
overflow-y: visible;
}
}

View file

@ -70,6 +70,7 @@ in the form $setting: value,
----------------------------*/
$theme-font-weight-medium: 400,
$theme-font-weight-semibold: 600,
$theme-font-weight-bold: 700,
/*---------------------------
## Font roles

View file

@ -107,6 +107,7 @@ DEBUG = env_debug
# Controls production specific feature toggles
IS_PRODUCTION = env_is_production
SECRET_ENCRYPT_METADATA = secret_encrypt_metadata
BASE_URL = env_base_url
# Applications are modular pieces of code.
# They are provided by Django, by third-parties, or by yourself.
@ -200,6 +201,8 @@ MIDDLEWARE = [
"waffle.middleware.WaffleMiddleware",
"registrar.registrar_middleware.CheckUserProfileMiddleware",
"registrar.registrar_middleware.CheckPortfolioMiddleware",
# Restrict access using Opt-Out approach
"registrar.registrar_middleware.RestrictAccessMiddleware",
]
# application object used by Django's built-in servers (e.g. `runserver`)

View file

@ -68,7 +68,7 @@ for step, view in [
(PortfolioDomainRequestStep.REQUESTING_ENTITY, views.RequestingEntity),
(PortfolioDomainRequestStep.ADDITIONAL_DETAILS, views.PortfolioAdditionalDetails),
]:
domain_request_urls.append(path(f"<int:id>/{step}/", view.as_view(), name=step))
domain_request_urls.append(path(f"<int:domain_request_pk>/{step}/", view.as_view(), name=step))
urlpatterns = [
@ -260,27 +260,27 @@ urlpatterns = [
name="export_data_type_user",
),
path(
"domain-request/<int:id>/edit/",
"domain-request/<int:domain_request_pk>/edit/",
views.DomainRequestWizard.as_view(),
name=views.DomainRequestWizard.EDIT_URL_NAME,
),
path(
"domain-request/<int:pk>",
"domain-request/<int:domain_request_pk>",
views.DomainRequestStatus.as_view(),
name="domain-request-status",
),
path(
"domain-request/viewonly/<int:pk>",
"domain-request/viewonly/<int:domain_request_pk>",
views.PortfolioDomainRequestStatusViewOnly.as_view(),
name="domain-request-status-viewonly",
),
path(
"domain-request/<int:pk>/withdraw",
"domain-request/<int:domain_request_pk>/withdraw",
views.DomainRequestWithdrawConfirmation.as_view(),
name="domain-request-withdraw-confirmation",
),
path(
"domain-request/<int:pk>/withdrawconfirmed",
"domain-request/<int:domain_request_pk>/withdrawconfirmed",
views.DomainRequestWithdrawn.as_view(),
name="domain-request-withdrawn",
),
@ -296,56 +296,60 @@ urlpatterns = [
lambda r: always_404(r, "We forgot to include this link, sorry."),
name="todo",
),
path("domain/<int:pk>", views.DomainView.as_view(), name="domain"),
path("domain/<int:pk>/prototype-dns", views.PrototypeDomainDNSRecordView.as_view(), name="prototype-domain-dns"),
path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path("domain/<int:domain_pk>", views.DomainView.as_view(), name="domain"),
path(
"domain/<int:pk>/dns",
"domain/<int:domain_pk>/prototype-dns",
views.PrototypeDomainDNSRecordView.as_view(),
name="prototype-domain-dns",
),
path("domain/<int:domain_pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path(
"domain/<int:domain_pk>/dns",
views.DomainDNSView.as_view(),
name="domain-dns",
),
path(
"domain/<int:pk>/dns/nameservers",
"domain/<int:domain_pk>/dns/nameservers",
views.DomainNameserversView.as_view(),
name="domain-dns-nameservers",
),
path(
"domain/<int:pk>/dns/dnssec",
"domain/<int:domain_pk>/dns/dnssec",
views.DomainDNSSECView.as_view(),
name="domain-dns-dnssec",
),
path(
"domain/<int:pk>/dns/dnssec/dsdata",
"domain/<int:domain_pk>/dns/dnssec/dsdata",
views.DomainDsDataView.as_view(),
name="domain-dns-dnssec-dsdata",
),
path(
"domain/<int:pk>/org-name-address",
"domain/<int:domain_pk>/org-name-address",
views.DomainOrgNameAddressView.as_view(),
name="domain-org-name-address",
),
path(
"domain/<int:pk>/suborganization",
"domain/<int:domain_pk>/suborganization",
views.DomainSubOrganizationView.as_view(),
name="domain-suborganization",
),
path(
"domain/<int:pk>/senior-official",
"domain/<int:domain_pk>/senior-official",
views.DomainSeniorOfficialView.as_view(),
name="domain-senior-official",
),
path(
"domain/<int:pk>/security-email",
"domain/<int:domain_pk>/security-email",
views.DomainSecurityEmailView.as_view(),
name="domain-security-email",
),
path(
"domain/<int:pk>/renewal",
"domain/<int:domain_pk>/renewal",
views.DomainRenewalView.as_view(),
name="domain-renewal",
),
path(
"domain/<int:pk>/users/add",
"domain/<int:domain_pk>/users/add",
views.DomainAddUserView.as_view(),
name="domain-users-add",
),
@ -360,17 +364,17 @@ urlpatterns = [
name="user-profile",
),
path(
"invitation/<int:pk>/cancel",
"invitation/<int:domain_invitation_pk>/cancel",
views.DomainInvitationCancelView.as_view(http_method_names=["post"]),
name="invitation-cancel",
),
path(
"domain-request/<int:pk>/delete",
"domain-request/<int:domain_request_pk>/delete",
views.DomainRequestDeleteView.as_view(http_method_names=["post"]),
name="domain-request-delete",
),
path(
"domain/<int:pk>/users/<int:user_pk>/delete",
"domain/<int:domain_pk>/users/<int:user_pk>/delete",
views.DomainDeleteUserView.as_view(http_method_names=["post"]),
name="domain-user-delete",
),
@ -392,6 +396,7 @@ urlpatterns = [
# This way, we can share a view for djangooidc, and other pages as we see fit.
handler500 = "registrar.views.utility.error_views.custom_500_error_view"
handler403 = "registrar.views.utility.error_views.custom_403_error_view"
handler404 = "registrar.views.utility.error_views.custom_404_error_view"
# we normally would guard these with `if settings.DEBUG` but tests run with
# DEBUG = False even when these apps have been loaded because settings.DEBUG

View file

@ -68,19 +68,9 @@ def portfolio_permissions(request):
"has_organization_requests_flag": False,
"has_organization_members_flag": False,
"is_portfolio_admin": False,
"has_domain_renewal_flag": False,
}
try:
portfolio = request.session.get("portfolio")
# These feature flags will display and doesn't depend on portfolio
portfolio_context.update(
{
"has_organization_feature_flag": True,
"has_domain_renewal_flag": request.user.has_domain_renewal_flag(),
}
)
if portfolio:
return {
"has_view_portfolio_permission": request.user.has_view_portfolio_permission(portfolio),
@ -95,7 +85,6 @@ def portfolio_permissions(request):
"has_organization_requests_flag": request.user.has_organization_requests_flag(),
"has_organization_members_flag": request.user.has_organization_members_flag(),
"is_portfolio_admin": request.user.is_portfolio_admin(portfolio),
"has_domain_renewal_flag": request.user.has_domain_renewal_flag(),
}
return portfolio_context

300
src/registrar/decorators.py Normal file
View file

@ -0,0 +1,300 @@
import functools
from django.core.exceptions import PermissionDenied
from django.utils.decorators import method_decorator
from registrar.models import Domain, DomainInformation, DomainInvitation, DomainRequest, UserDomainRole
# Constants for clarity
ALL = "all"
IS_STAFF = "is_staff"
IS_DOMAIN_MANAGER = "is_domain_manager"
IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator"
IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain"
IS_PORTFOLIO_MEMBER = "is_portfolio_member"
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER = "is_portfolio_member_and_domain_manager"
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER = "is_domain_manager_and_not_portfolio_member"
HAS_PORTFOLIO_DOMAINS_ANY_PERM = "has_portfolio_domains_any_perm"
HAS_PORTFOLIO_DOMAINS_VIEW_ALL = "has_portfolio_domains_view_all"
HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM = "has_portfolio_domain_requests_any_perm"
HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL = "has_portfolio_domain_requests_view_all"
HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT = "has_portfolio_domain_requests_edit"
HAS_PORTFOLIO_MEMBERS_ANY_PERM = "has_portfolio_members_any_perm"
HAS_PORTFOLIO_MEMBERS_EDIT = "has_portfolio_members_edit"
HAS_PORTFOLIO_MEMBERS_VIEW = "has_portfolio_members_view"
def grant_access(*rules):
"""
A decorator that enforces access control based on specified rules.
Usage:
- Multiple rules in a single decorator:
@grant_access(IS_STAFF, IS_SUPERUSER, IS_DOMAIN_MANAGER)
- Stacked decorators for separate rules:
@grant_access(IS_SUPERUSER)
@grant_access(IS_DOMAIN_MANAGER)
The decorator supports both function-based views (FBVs) and class-based views (CBVs).
"""
def decorator(view):
if isinstance(view, type): # Check if decorating a class-based view (CBV)
original_dispatch = view.dispatch # Store the original dispatch method
@method_decorator(grant_access(*rules)) # Apply the decorator to dispatch
def wrapped_dispatch(self, request, *args, **kwargs):
if not _user_has_permission(request.user, request, rules, **kwargs):
raise PermissionDenied # Deny access if the user lacks permission
return original_dispatch(self, request, *args, **kwargs)
view.dispatch = wrapped_dispatch # Replace the dispatch method
return view
else: # If decorating a function-based view (FBV)
view.has_explicit_access = True # Mark the view as having explicit access control
existing_rules = getattr(view, "_access_rules", set()) # Retrieve existing rules
existing_rules.update(rules) # Merge with new rules
view._access_rules = existing_rules # Store updated rules
@functools.wraps(view)
def wrapper(request, *args, **kwargs):
if not _user_has_permission(request.user, request, rules, **kwargs):
raise PermissionDenied # Deny access if the user lacks permission
return view(request, *args, **kwargs) # Proceed with the original view
return wrapper
return decorator
def _user_has_permission(user, request, rules, **kwargs):
"""
Determines if the user meets the required permission rules.
This function evaluates a set of predefined permission rules to check whether a user has access
to a specific view. It supports various access control conditions, including staff status,
domain management roles, and portfolio-related permissions.
Parameters:
- user: The user requesting access.
- request: The HTTP request object.
- rules: A set of access control rules to evaluate.
- **kwargs: Additional keyword arguments used in specific permission checks.
Returns:
- True if the user satisfies any of the specified rules.
- False otherwise.
"""
# Skip authentication if @login_not_required is applied
if getattr(request, "login_not_required", False):
return True
# Allow everyone if `ALL` is in rules
if ALL in rules:
return True
# Ensure user is authenticated and not restricted
if not user.is_authenticated or user.is_restricted():
return False
# Define permission checks
permission_checks = [
(IS_STAFF, lambda: user.is_staff),
(IS_DOMAIN_MANAGER, lambda: _is_domain_manager(user, **kwargs)),
(IS_STAFF_MANAGING_DOMAIN, lambda: _is_staff_managing_domain(request, **kwargs)),
(IS_PORTFOLIO_MEMBER, lambda: user.is_org_user(request)),
(
HAS_PORTFOLIO_DOMAINS_VIEW_ALL,
lambda: _has_portfolio_view_all_domains(request, kwargs.get("domain_pk")),
),
(
HAS_PORTFOLIO_DOMAINS_ANY_PERM,
lambda: user.is_org_user(request)
and user.has_any_domains_portfolio_permission(request.session.get("portfolio")),
),
(
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER,
lambda: _is_domain_manager(user, **kwargs) and _is_portfolio_member(request),
),
(
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER,
lambda: _is_domain_manager(user, **kwargs) and not _is_portfolio_member(request),
),
(
IS_DOMAIN_REQUEST_CREATOR,
lambda: _is_domain_request_creator(user, kwargs.get("domain_request_pk"))
and not _is_portfolio_member(request),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM,
lambda: user.is_org_user(request)
and user.has_any_requests_portfolio_permission(request.session.get("portfolio")),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL,
lambda: user.is_org_user(request)
and user.has_view_all_domain_requests_portfolio_permission(request.session.get("portfolio")),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT,
lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk")),
),
(
HAS_PORTFOLIO_MEMBERS_ANY_PERM,
lambda: user.is_org_user(request)
and (
user.has_view_members_portfolio_permission(request.session.get("portfolio"))
or user.has_edit_members_portfolio_permission(request.session.get("portfolio"))
),
),
(
HAS_PORTFOLIO_MEMBERS_EDIT,
lambda: user.is_org_user(request)
and user.has_edit_members_portfolio_permission(request.session.get("portfolio")),
),
(
HAS_PORTFOLIO_MEMBERS_VIEW,
lambda: user.is_org_user(request)
and user.has_view_members_portfolio_permission(request.session.get("portfolio")),
),
]
# Check conditions iteratively
return any(check() for rule, check in permission_checks if rule in rules)
def _has_portfolio_domain_requests_edit(user, request, domain_request_id):
if domain_request_id and not _is_domain_request_creator(user, domain_request_id):
return False
return user.is_org_user(request) and user.has_edit_request_portfolio_permission(request.session.get("portfolio"))
def _is_domain_manager(user, **kwargs):
"""
Determines if the given user is a domain manager for a specified domain.
- First, it checks if 'domain_pk' is present in the URL parameters.
- If 'domain_pk' exists, it verifies if the user has a domain role for that domain.
- If 'domain_pk' is absent, it checks for 'domain_invitation_pk' to determine if the user
has domain permissions through an invitation.
Returns:
bool: True if the user is a domain manager, False otherwise.
"""
domain_id = kwargs.get("domain_pk")
if domain_id:
return UserDomainRole.objects.filter(user=user, domain_id=domain_id).exists()
domain_invitation_id = kwargs.get("domain_invitation_pk")
if domain_invitation_id:
return DomainInvitation.objects.filter(id=domain_invitation_id, domain__permissions__user=user).exists()
return False
def _is_domain_request_creator(user, domain_request_pk):
"""Checks to see if the user is the creator of a domain request
with domain_request_pk."""
if domain_request_pk:
return DomainRequest.objects.filter(creator=user, id=domain_request_pk).exists()
return True
def _is_portfolio_member(request):
"""Checks to see if the user in the request is a member of the
portfolio in the request's session."""
return request.user.is_org_user(request)
def _is_staff_managing_domain(request, **kwargs):
"""
Determines whether a staff user (analyst or superuser) has permission to manage a domain
that they did not create or were not invited to.
The function enforces:
1. **User Authorization** - The user must have `analyst_access_permission` or `full_access_permission`.
2. **Valid Session Context** - The user must have explicitly selected the domain for management
via an 'analyst action' (e.g., by clicking 'Manage Domain' in the admin interface).
3. **Domain Status Check** - Only domains in specific statuses (e.g., APPROVED, IN_REVIEW, etc.)
can be managed, except in cases where the domain lacks a status due to errors.
Process:
- First, the function retrieves the `domain_pk` from the URL parameters.
- If `domain_pk` is not provided, it attempts to resolve the domain via `domain_invitation_pk`.
- It checks if the user has the required permissions.
- It verifies that the user has an active 'analyst action' session for the domain.
- Finally, it ensures that the domain is in a status that allows management.
Returns:
bool: True if the user is allowed to manage the domain, False otherwise.
"""
domain_id = kwargs.get("domain_pk")
if not domain_id:
domain_invitation_id = kwargs.get("domain_invitation_pk")
domain_invitation = DomainInvitation.objects.filter(id=domain_invitation_id).first()
if domain_invitation:
domain_id = domain_invitation.domain_id
# Check if the request user is permissioned...
user_is_analyst_or_superuser = request.user.has_perm(
"registrar.analyst_access_permission"
) or request.user.has_perm("registrar.full_access_permission")
if not user_is_analyst_or_superuser:
return False
# Check if the user is attempting a valid edit action.
# In other words, if the analyst/admin did not click
# the 'Manage Domain' button in /admin,
# then they cannot access this page.
session = request.session
can_do_action = (
"analyst_action" in session
and "analyst_action_location" in session
and session["analyst_action_location"] == domain_id
)
if not can_do_action:
return False
# Analysts may manage domains, when they are in these statuses:
valid_domain_statuses = [
DomainRequest.DomainRequestStatus.APPROVED,
DomainRequest.DomainRequestStatus.IN_REVIEW,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
# Edge case - some domains do not have
# a status or DomainInformation... aka a status of 'None'.
# It is necessary to access those to correct errors.
None,
]
requested_domain = DomainInformation.objects.filter(domain_id=domain_id).first()
# if no domain information or domain request exist, the user
# should be able to manage the domain; however, if domain information
# and domain request exist, and domain request is not in valid status,
# user should not be able to manage domain
if (
requested_domain
and requested_domain.domain_request
and requested_domain.domain_request.status not in valid_domain_statuses
):
return False
# Valid session keys exist,
# the user is permissioned,
# and it is in a valid status
return True
def _has_portfolio_view_all_domains(request, domain_pk):
"""Returns whether the user in the request can access the domain
via portfolio view all domains permission."""
portfolio = request.session.get("portfolio")
if request.user.has_view_all_domains_portfolio_permission(portfolio):
if Domain.objects.filter(id=domain_pk).exists():
domain = Domain.objects.get(id=domain_pk)
if domain.domain_info.portfolio == portfolio:
return True
return False

View file

@ -171,6 +171,13 @@ class UserFixture:
"email": "gina.summers@ecstech.com",
"title": "Scrum Master",
},
{
"username": "89f2db87-87a2-4778-a5ea-5b27b585b131",
"first_name": "Jaxon",
"last_name": "Silva",
"email": "jaxon.silva@cisa.dhs.gov",
"title": "Designer",
},
]
STAFF = [

View file

@ -13,7 +13,16 @@ from registrar.models import (
Portfolio,
SeniorOfficial,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.utility.portfolio_helper import (
UserPortfolioPermissionChoices,
UserPortfolioRoleChoices,
get_domain_requests_description_display,
get_domain_requests_display,
get_domains_description_display,
get_domains_display,
get_members_description_display,
get_members_display,
)
logger = logging.getLogger(__name__)
@ -126,8 +135,16 @@ class BasePortfolioMemberForm(forms.ModelForm):
domain_permissions = forms.ChoiceField(
choices=[
(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"),
(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"),
(
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
get_domains_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
),
(
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value,
get_domains_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
),
),
],
widget=forms.RadioSelect,
required=False,
@ -139,9 +156,19 @@ class BasePortfolioMemberForm(forms.ModelForm):
domain_request_permissions = forms.ChoiceField(
choices=[
("no_access", "No access"),
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "Viewer"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Creator"),
("no_access", get_domain_requests_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None)),
(
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
get_domain_requests_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS]
),
),
(
UserPortfolioPermissionChoices.EDIT_REQUESTS.value,
get_domain_requests_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS]
),
),
],
widget=forms.RadioSelect,
required=False,
@ -153,8 +180,13 @@ class BasePortfolioMemberForm(forms.ModelForm):
member_permissions = forms.ChoiceField(
choices=[
("no_access", "No access"),
(UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "Viewer"),
("no_access", get_members_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None)),
(
UserPortfolioPermissionChoices.VIEW_MEMBERS.value,
get_members_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS]
),
),
],
widget=forms.RadioSelect,
required=False,
@ -191,19 +223,31 @@ class BasePortfolioMemberForm(forms.ModelForm):
# Adds a <p> description beneath each option
self.fields["domain_permissions"].descriptions = {
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: "Can view only the domains they manage",
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: "Can view all domains for the organization",
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: get_domains_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None
),
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: get_domains_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
),
}
self.fields["domain_request_permissions"].descriptions = {
UserPortfolioPermissionChoices.EDIT_REQUESTS.value: (
"Can view all domain requests for the organization and create requests"
get_domain_requests_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS]
)
),
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: "Can view all domain requests for the organization",
"no_access": "Cannot view or create domain requests",
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: (
get_domain_requests_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS]
)
),
"no_access": get_domain_requests_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
}
self.fields["member_permissions"].descriptions = {
UserPortfolioPermissionChoices.VIEW_MEMBERS.value: "Can view all members permissions",
"no_access": "Cannot view member permissions",
UserPortfolioPermissionChoices.VIEW_MEMBERS.value: get_members_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS]
),
"no_access": get_members_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
}
# Map model instance values to custom form fields
@ -218,6 +262,9 @@ class BasePortfolioMemberForm(forms.ModelForm):
cleaned_data = super().clean()
role = cleaned_data.get("role")
# handle role
cleaned_data["roles"] = [role] if role else []
# Get required fields for the selected role. Then validate all required fields for the role.
required_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
for field_name in required_fields:
@ -236,9 +283,6 @@ class BasePortfolioMemberForm(forms.ModelForm):
if cleaned_data.get("member_permissions") == "no_access":
cleaned_data["member_permissions"] = None
# Handle roles
cleaned_data["roles"] = [role]
# Handle additional_permissions
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}
@ -338,6 +382,24 @@ class BasePortfolioMemberForm(forms.ModelForm):
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles
)
def is_change(self) -> bool:
"""
Determines if the form has changed by comparing the initial data
with the submitted cleaned data.
Returns:
bool: True if the form has changed, False otherwise.
"""
# Compare role values
previous_roles = set(self.initial.get("roles", []))
new_roles = set(self.cleaned_data.get("roles", []))
# Compare additional permissions values
previous_permissions = set(self.initial.get("additional_permissions") or [])
new_permissions = set(self.cleaned_data.get("additional_permissions") or [])
return previous_roles != new_roles or previous_permissions != new_permissions
class PortfolioMemberForm(BasePortfolioMemberForm):
"""

View file

@ -1,4 +1,4 @@
""""
""" "
Converts all ready and DNS needed domains with a non-default public contact
to disclose their public contact. Created for Issue#1535 to resolve
disclose issue of domains with missing security emails.

View file

@ -1,8 +1,8 @@
"""Data migration:
1 - generates a report of data integrity across all
transition domain related tables
2 - allows users to run all migration scripts for
transition domain data
1 - generates a report of data integrity across all
transition domain related tables
2 - allows users to run all migration scripts for
transition domain data
"""
import logging

View file

@ -1,4 +1,4 @@
""""
""" "
Data migration: Renaming deprecated Federal Agencies to
their new updated names ie (U.S. Peace Corps to Peace Corps)
within Domain Information and Domain Requests

View file

@ -2,6 +2,7 @@ from itertools import zip_longest
import logging
import ipaddress
import re
import time
from datetime import date, timedelta
from typing import Optional
from django.db import transaction
@ -41,7 +42,6 @@ from .utility.time_stamped_model import TimeStampedModel
from .public_contact import PublicContact
from .user_domain_role import UserDomainRole
from waffle.decorators import flag_is_active
logger = logging.getLogger(__name__)
@ -751,11 +751,7 @@ class Domain(TimeStampedModel, DomainHelper):
successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
try:
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
except Exception as e:
# we don't need this part to succeed in order to continue.
logger.error("Failed to delete nameserver hosts: %s", e)
if successTotalNameservers < 2:
try:
@ -1039,13 +1035,13 @@ class Domain(TimeStampedModel, DomainHelper):
logger.error(f"registry error removing client hold: {err}")
raise (err)
def _delete_domain(self):
def _delete_domain(self): # noqa
"""This domain should be deleted from the registry
may raises RegistryError, should be caught or handled correctly by caller"""
logger.info("Deleting subdomains for %s", self.name)
# check if any subdomains are in use by another domain
hosts = Host.objects.filter(name__regex=r".+{}".format(self.name))
hosts = Host.objects.filter(name__regex=r".+\.{}".format(self.name))
for host in hosts:
if host.domain != self:
logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain)
@ -1053,7 +1049,8 @@ class Domain(TimeStampedModel, DomainHelper):
code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION,
note=f"Host {host.name} is in use by {host.domain}",
)
try:
# set hosts to empty list so nameservers are deleted
(
deleted_values,
updated_values,
@ -1061,30 +1058,110 @@ class Domain(TimeStampedModel, DomainHelper):
oldNameservers,
) = self.getNameserverChanges(hosts=[])
_ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors
# update the hosts
_ = self._update_host_values(
updated_values, oldNameservers
) # returns nothing, just need to be run and errors
addToDomainList, _ = self.createNewHostList(new_values)
deleteHostList, _ = self.createDeleteHostList(deleted_values)
responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
except RegistryError as e:
logger.error(f"Error trying to delete hosts from domain {self}: {e}")
raise e
# if unable to update domain raise error and stop
if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
raise NameserverError(code=nsErrorCodes.BAD_DATA)
logger.info("Finished removing nameservers from domain")
# addAndRemoveHostsFromDomain removes the hosts from the domain object,
# but we still need to delete the object themselves
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
logger.info("Finished _delete_hosts_if_not_used inside _delete_domain()")
# delete the non-registrant contacts
logger.debug("Deleting non-registrant contacts for %s", self.name)
contacts = PublicContact.objects.filter(domain=self)
logger.info(f"retrieved contacts for domain: {contacts}")
for contact in contacts:
try:
if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
logger.info(f"Deleting contact: {contact}")
try:
self._update_domain_with_contact(contact, rem=True)
except Exception as e:
logger.error(f"Error while updating domain with contact: {contact}, e: {e}", exc_info=True)
request = commands.DeleteContact(contact.registry_id)
registry.send(request, cleaned=True)
logger.info(f"sent DeleteContact for {contact}")
except RegistryError as e:
logger.error(f"Error deleting contact: {contact}, {e}", exc_info=True)
logger.info("Deleting domain %s", self.name)
logger.info(f"Finished deleting contacts for {self.name}")
# delete ds data if it exists
if self.dnssecdata:
logger.debug("Deleting ds data for %s", self.name)
try:
# set and unset client hold to be able to change ds data
logger.info("removing client hold")
self._remove_client_hold()
self.dnssecdata = None
logger.info("placing client hold")
self._place_client_hold()
except RegistryError as e:
logger.error("Error deleting ds data for %s: %s", self.name, e)
e.note = "Error deleting ds data for %s" % self.name
raise e
# check if the domain can be deleted
if not self._domain_can_be_deleted():
note = "Domain has associated objects that prevent deletion."
raise RegistryError(code=ErrorCode.COMMAND_FAILED, note=note)
# delete the domain
request = commands.DeleteDomain(name=self.name)
try:
registry.send(request, cleaned=True)
logger.info("Domain %s deleted successfully.", self.name)
except RegistryError as e:
logger.error("Error deleting domain %s: %s", self.name, e)
raise e
def _domain_can_be_deleted(self, max_attempts=5, wait_interval=2) -> bool:
"""
Polls the registry using InfoDomain calls to confirm that the domain can be deleted.
Returns True if the domain can be deleted, False otherwise. Includes a retry mechanism
using wait_interval and max_attempts, which may be necessary if subdomains and other
associated objects were only recently deleted as the registry may not be immediately updated.
"""
logger.info("Polling registry to confirm deletion pre-conditions for %s", self.name)
last_info_error = None
for attempt in range(max_attempts):
try:
info_response = registry.send(commands.InfoDomain(name=self.name), cleaned=True)
domain_info = info_response.res_data[0]
hosts_associated = getattr(domain_info, "hosts", None)
if hosts_associated is None or len(hosts_associated) == 0:
logger.info("InfoDomain reports no associated hosts for %s. Proceeding with deletion.", self.name)
return True
else:
logger.info("Attempt %d: Domain %s still has hosts: %s", attempt + 1, self.name, hosts_associated)
except RegistryError as info_e:
# If the domain is already gone, we can assume deletion already occurred.
if info_e.code == ErrorCode.OBJECT_DOES_NOT_EXIST:
logger.info("InfoDomain check indicates domain %s no longer exists.", self.name)
raise info_e
logger.warning("Attempt %d: Error during InfoDomain check: %s", attempt + 1, info_e)
time.sleep(wait_interval)
else:
logger.error(
"Exceeded max attempts waiting for domain %s to clear associated objects; last error: %s",
self.name,
last_info_error,
)
return False
def __str__(self) -> str:
return self.name
@ -1172,7 +1249,7 @@ class Domain(TimeStampedModel, DomainHelper):
"""Return the display status of the domain."""
if self.is_expired() and (self.state != self.State.UNKNOWN):
return "Expired"
elif flag_is_active(request, "domain_renewal") and self.is_expiring():
elif self.is_expiring():
return "Expiring soon"
elif self.state == self.State.UNKNOWN or self.state == self.State.DNS_NEEDED:
return "DNS needed"
@ -1588,7 +1665,7 @@ class Domain(TimeStampedModel, DomainHelper):
# Given expired is not a physical state, but it is displayed as such,
# We need custom logic to determine this message.
help_text = "This domain has expired. Complete the online renewal process to maintain access."
elif flag_is_active(request, "domain_renewal") and self.is_expiring():
elif self.is_expiring():
help_text = "This domain is expiring soon. Complete the online renewal process to maintain access."
else:
help_text = Domain.State.get_help_text(self.state)
@ -1841,8 +1918,6 @@ class Domain(TimeStampedModel, DomainHelper):
else:
logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e))
raise e
def _fix_unknown_state(self, cleaned):
"""
_fix_unknown_state: Calls _add_missing_contacts_if_unknown

View file

@ -9,6 +9,13 @@ from .utility.portfolio_helper import (
UserPortfolioPermissionChoices,
UserPortfolioRoleChoices,
cleanup_after_portfolio_member_deletion,
get_domain_requests_description_display,
get_domain_requests_display,
get_domains_description_display,
get_domains_display,
get_members_description_display,
get_members_display,
get_role_display,
validate_portfolio_invitation,
) # type: ignore
from .utility.time_stamped_model import TimeStampedModel
@ -85,6 +92,90 @@ class PortfolioInvitation(TimeStampedModel):
"""
return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
@property
def role_display(self):
"""
Returns a human-readable display name for the user's role.
Uses the `get_role_display` function to determine if the user is an "Admin",
"Basic" member, or has no role assigned.
Returns:
str: The display name of the user's role.
"""
return get_role_display(self.roles)
@property
def domains_display(self):
"""
Returns a string representation of the user's domain access level.
Uses the `get_domains_display` function to determine whether the user has
"Viewer" access (can view all domains) or "Viewer, limited" access.
Returns:
str: The display name of the user's domain permissions.
"""
return get_domains_display(self.roles, self.additional_permissions)
@property
def domains_description_display(self):
"""
Returns a string description of the user's domain access level.
Returns:
str: The display name of the user's domain permissions description.
"""
return get_domains_description_display(self.roles, self.additional_permissions)
@property
def domain_requests_display(self):
"""
Returns a string representation of the user's access to domain requests.
Uses the `get_domain_requests_display` function to determine if the user
is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
or has "No access" to domain requests.
Returns:
str: The display name of the user's domain request permissions.
"""
return get_domain_requests_display(self.roles, self.additional_permissions)
@property
def domain_requests_description_display(self):
"""
Returns a string description of the user's access to domain requests.
Returns:
str: The display name of the user's domain request permissions description.
"""
return get_domain_requests_description_display(self.roles, self.additional_permissions)
@property
def members_display(self):
"""
Returns a string representation of the user's access to managing members.
Uses the `get_members_display` function to determine if the user is a
"Manager" (can edit members), a "Viewer" (can view members), or has "No access"
to member management.
Returns:
str: The display name of the user's member management permissions.
"""
return get_members_display(self.roles, self.additional_permissions)
@property
def members_description_display(self):
"""
Returns a string description of the user's access to managing members.
Returns:
str: The display name of the user's member management permissions description.
"""
return get_members_description_display(self.roles, self.additional_permissions)
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission.

View file

@ -269,10 +269,7 @@ class User(AbstractUser):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
def is_portfolio_admin(self, portfolio):
return "Admin" in self.portfolio_role_summary(portfolio)
def has_domain_renewal_flag(self):
return flag_is_active_for_user(self, "domain_renewal")
return self.has_edit_portfolio_permission(portfolio)
def get_first_portfolio(self):
permission = self.portfolio_permissions.first()
@ -280,49 +277,6 @@ class User(AbstractUser):
return permission.portfolio
return None
def portfolio_role_summary(self, portfolio):
"""Returns a list of roles based on the user's permissions."""
roles = []
# Define the conditions and their corresponding roles
conditions_roles = [
(self.has_edit_portfolio_permission(portfolio), ["Admin"]),
(
self.has_view_all_domains_portfolio_permission(portfolio)
and self.has_any_requests_portfolio_permission(portfolio)
and self.has_edit_request_portfolio_permission(portfolio),
["View-only admin", "Domain requestor"],
),
(
self.has_view_all_domains_portfolio_permission(portfolio)
and self.has_any_requests_portfolio_permission(portfolio),
["View-only admin"],
),
(
self.has_view_portfolio_permission(portfolio)
and self.has_edit_request_portfolio_permission(portfolio)
and self.has_any_domains_portfolio_permission(portfolio),
["Domain requestor", "Domain manager"],
),
(
self.has_view_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
["Domain requestor"],
),
(
self.has_view_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
["Domain manager"],
),
(self.has_view_portfolio_permission(portfolio), ["Member"]),
]
# Evaluate conditions and add roles
for condition, role_list in conditions_roles:
if condition:
roles.extend(role_list)
break
return roles
def get_portfolios(self):
return self.portfolio_permissions.all()

View file

@ -6,6 +6,13 @@ from registrar.models.utility.portfolio_helper import (
DomainRequestPermissionDisplay,
MemberPermissionDisplay,
cleanup_after_portfolio_member_deletion,
get_domain_requests_display,
get_domain_requests_description_display,
get_domains_display,
get_domains_description_display,
get_members_display,
get_members_description_display,
get_role_display,
validate_user_portfolio_permission,
)
from .utility.time_stamped_model import TimeStampedModel
@ -181,6 +188,90 @@ class UserPortfolioPermission(TimeStampedModel):
# This is the same as portfolio_permissions & common_forbidden_perms.
return portfolio_permissions.intersection(common_forbidden_perms)
@property
def role_display(self):
"""
Returns a human-readable display name for the user's role.
Uses the `get_role_display` function to determine if the user is an "Admin",
"Basic" member, or has no role assigned.
Returns:
str: The display name of the user's role.
"""
return get_role_display(self.roles)
@property
def domains_display(self):
"""
Returns a string representation of the user's domain access level.
Uses the `get_domains_display` function to determine whether the user has
"Viewer" access (can view all domains) or "Viewer, limited" access.
Returns:
str: The display name of the user's domain permissions.
"""
return get_domains_display(self.roles, self.additional_permissions)
@property
def domains_description_display(self):
"""
Returns a string description of the user's domain access level.
Returns:
str: The display name of the user's domain permissions description.
"""
return get_domains_description_display(self.roles, self.additional_permissions)
@property
def domain_requests_display(self):
"""
Returns a string representation of the user's access to domain requests.
Uses the `get_domain_requests_display` function to determine if the user
is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
or has "No access" to domain requests.
Returns:
str: The display name of the user's domain request permissions.
"""
return get_domain_requests_display(self.roles, self.additional_permissions)
@property
def domain_requests_description_display(self):
"""
Returns a string description of the user's access to domain requests.
Returns:
str: The display name of the user's domain request permissions description.
"""
return get_domain_requests_description_display(self.roles, self.additional_permissions)
@property
def members_display(self):
"""
Returns a string representation of the user's access to managing members.
Uses the `get_members_display` function to determine if the user is a
"Manager" (can edit members), a "Viewer" (can view members), or has "No access"
to member management.
Returns:
str: The display name of the user's member management permissions.
"""
return get_members_display(self.roles, self.additional_permissions)
@property
def members_description_display(self):
"""
Returns a string description of the user's access to managing members.
Returns:
str: The display name of the user's member management permissions description.
"""
return get_members_description_display(self.roles, self.additional_permissions)
def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()

View file

@ -79,6 +79,161 @@ class MemberPermissionDisplay(StrEnum):
NONE = "None"
def get_role_display(roles):
"""
Returns a user-friendly display name for a given list of user roles.
- If the user has the ORGANIZATION_ADMIN role, return "Admin".
- If the user has the ORGANIZATION_MEMBER role, return "Basic".
- If the user has neither role, return "-".
Args:
roles (list): A list of role strings assigned to the user.
Returns:
str: The display name for the highest applicable role.
"""
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles:
return "Admin"
elif UserPortfolioRoleChoices.ORGANIZATION_MEMBER in roles:
return "Basic"
else:
return "-"
def get_domains_display(roles, permissions):
"""
Determines the display name for a user's domain viewing permissions.
- If the user has the VIEW_ALL_DOMAINS permission, return "Viewer".
- Otherwise, return "Viewer, limited".
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain viewing access.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions:
return "Viewer"
else:
return "Viewer, limited"
def get_domains_description_display(roles, permissions):
"""
Determines the display description for a user's domain viewing permissions.
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain viewing access description.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions:
return "Can view all domains for the organization"
else:
return "Can view only the domains they manage"
def get_domain_requests_display(roles, permissions):
"""
Determines the display name for a user's domain request permissions.
- If the user has the EDIT_REQUESTS permission, return "Creator".
- If the user has the VIEW_ALL_REQUESTS permission, return "Viewer".
- Otherwise, return "No access".
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain request access level.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions:
return "Creator"
elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
return "Viewer"
else:
return "No access"
def get_domain_requests_description_display(roles, permissions):
"""
Determines the display description for a user's domain request permissions.
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain request access level description.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions:
return "Can view all domain requests for the organization and create requests"
elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
return "Can view all domain requests for the organization"
else:
return "Cannot view or create domain requests"
def get_members_display(roles, permissions):
"""
Determines the display name for a user's member management permissions.
- If the user has the EDIT_MEMBERS permission, return "Manager".
- If the user has the VIEW_MEMBERS permission, return "Viewer".
- Otherwise, return "No access".
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's member management access level.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
return "Manager"
elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
return "Viewer"
else:
return "No access"
def get_members_description_display(roles, permissions):
"""
Determines the display description for a user's member management permissions.
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's member management access level description.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
return "Can view and manage all member permissions"
elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
return "Can view all member permissions"
else:
return "Cannot view member permissions"
def validate_user_portfolio_permission(user_portfolio_permission):
"""
Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports

View file

@ -3,9 +3,13 @@ Contains middleware used in settings.py
"""
import logging
import re
from urllib.parse import parse_qs
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.http import HttpResponseRedirect
from django.urls import resolve
from registrar.models import User
from waffle.decorators import flag_is_active
@ -170,3 +174,51 @@ class CheckPortfolioMiddleware:
request.session["portfolio"] = request.user.get_first_portfolio()
else:
request.session["portfolio"] = request.user.get_first_portfolio()
class RestrictAccessMiddleware:
"""
Middleware that blocks access to all views unless explicitly permitted.
This middleware enforces authentication by default. Views must explicitly allow access
using access control mechanisms such as the `@grant_access` decorator. Exceptions are made
for Django admin views, explicitly ignored paths, and views that opt out of login requirements.
"""
def __init__(self, get_response):
self.get_response = get_response
# Compile regex patterns from settings to identify paths that bypass login requirements
self.ignored_paths = [re.compile(pattern) for pattern in getattr(settings, "LOGIN_REQUIRED_IGNORE_PATHS", [])]
def __call__(self, request):
# Allow requests to Django Debug Toolbar
if request.path.startswith("/__debug__/"):
return self.get_response(request)
# Allow requests matching configured ignored paths
if any(pattern.match(request.path) for pattern in self.ignored_paths):
return self.get_response(request)
# Attempt to resolve the request path to a view function
try:
resolver_match = resolve(request.path_info)
view_func = resolver_match.func
app_name = resolver_match.app_name # Get the app name of the resolved view
except Exception:
# If resolution fails, allow the request to proceed (avoid blocking non-view routes)
return self.get_response(request)
# Automatically allow access to Django's built-in admin views (excluding custom /admin/* views)
if app_name == "admin":
return self.get_response(request)
# Allow access if the view explicitly opts out of login requirements
if getattr(view_func, "login_required", True) is False:
return self.get_response(request)
# Restrict access to views that do not explicitly declare access rules
if not getattr(view_func, "has_explicit_access", False):
raise PermissionDenied # Deny access if the view lacks explicit permission handling
return self.get_response(request)

View file

@ -2,6 +2,10 @@
{% load static %}
{% load i18n %}
{% block title %}
Registrar Analytics | Django admin
{% endblock %}
{% block content_title %}<h1>Registrar Analytics</h1>{% endblock %}
{% block breadcrumbs %}
@ -18,7 +22,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% block content %}
<div id="content-main" class="custom-admin-template">
<div id="content-main" class="custom-admin-template analytics-dashboard">
<div class="grid-row grid-gap-2">
<div class="tablet:grid-col-6 margin-top-2">
@ -95,7 +99,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
<input type="date" id="end" name="end" value="2023-12-01" min="2023-12-01" />
</div>
</div>
<ul class="usa-button-group">
<ul class="usa-button-group flex-wrap">
<li class="usa-button-group__item">
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
@ -133,79 +137,126 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
</li>
</ul>
<div class="grid-row grid-gap-2 margin-y-2">
<div class="grid-col">
<canvas id="myChart1" width="400" height="200"
aria-label="Chart: {{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}"
<div class="analytics-dashboard-charts margin-top-2">
{% comment %} Managed/Unmanaged domains {% endcomment %}
<div class="chart-1 grid-col">
<canvas id="managed-domains-chart" width="400" height="200"
aria-label="Chart: {{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.managed_domains_sliced_at_start_date}}"
data-list-two="{{data.managed_domains_sliced_at_end_date}}"
data-list-one="{{ data.managed_domains.start_date_count }}"
data-list-two="{{ data.managed_domains.end_date_count }}"
>
<h2>Chart: Managed domains</h2>
<p>{{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}</p>
<p>{{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="grid-col">
<canvas id="myChart2" width="400" height="200"
aria-label="Chart: {{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}"
<div class="details-1 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for managed domains</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="managed_domains" %}
</div>
</details>
</div>
<div class="chart-2 grid-col">
<canvas id="unmanaged-domains-chart" width="400" height="200"
aria-label="Chart: {{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.unmanaged_domains_sliced_at_start_date}}"
data-list-two="{{data.unmanaged_domains_sliced_at_end_date}}"
data-list-one="{{ data.unmanaged_domains.start_date_count }}"
data-list-two="{{ data.unmanaged_domains.end_date_count }}"
>
<h2>Chart: Unmanaged domains</h2>
<p>{{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}</p>
<p>{{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="details-2 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for unmanaged domains</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="unmanaged_domains" %}
</div>
</details>
</div>
<div class="grid-row grid-gap-2 margin-y-2">
<div class="grid-col">
<canvas id="myChart3" width="400" height="200"
aria-label="Chart: {{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}"
{% comment %} Deleted/Ready domains {% endcomment %}
<div class="chart-3 grid-col">
<canvas id="deleted-domains-chart" width="400" height="200"
aria-label="Chart: {{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.deleted_domains_sliced_at_start_date}}"
data-list-two="{{data.deleted_domains_sliced_at_end_date}}"
data-list-one="{{ data.deleted_domains.start_date_count }}"
data-list-two="{{ data.deleted_domains.end_date_count }}"
>
<h2>Chart: Deleted domains</h2>
<p>{{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}</p>
<p>{{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="grid-col">
<canvas id="myChart4" width="400" height="200"
aria-label="Chart: {{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}"
<div class="details-3 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for deleted domains</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="deleted_domains" %}
</div>
</details>
</div>
<div class="chart-4 grid-col">
<canvas id="ready-domains-chart" width="400" height="200"
aria-label="Chart: {{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.ready_domains_sliced_at_start_date}}"
data-list-two="{{data.ready_domains_sliced_at_end_date}}"
data-list-one="{{ data.ready_domains.start_date_count }}"
data-list-two="{{ data.ready_domains.end_date_count }}"
>
<h2>Chart: Ready domains</h2>
<p>{{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}</p>
<p>{{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="details-4 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for ready domains</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="ready_domains" %}
</div>
</details>
</div>
<div class="grid-row grid-gap-2 margin-y-2">
<div class="grid-col">
<canvas id="myChart5" width="400" height="200"
aria-label="Chart: {{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}"
{% comment %} Requests {% endcomment %}
<div class="chart-5 grid-col">
<canvas id="submitted-requests-chart" width="400" height="200"
aria-label="Chart: {{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}"
role="img"
data-list-one="{{data.submitted_requests_sliced_at_start_date}}"
data-list-two="{{data.submitted_requests_sliced_at_end_date}}"
data-list-one="{{ data.submitted_requests.start_date_count }}"
data-list-two="{{ data.submitted_requests.end_date_count }}"
>
<h2>Chart: Submitted requests</h2>
<p>{{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}</p>
<p>{{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}</p>
</canvas>
</div>
<div class="grid-col">
<canvas id="myChart6" width="400" height="200"
aria-label="Chart: {{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}"
<div class="details-5 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for submitted requests</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="submitted_requests" %}
</div>
</details>
</div>
<div class="chart-6 grid-col">
<canvas id="all-requests-chart" width="400" height="200"
aria-label="Chart: {{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}"
role="img"
data-list-one="{{data.requests_sliced_at_start_date}}"
data-list-two="{{data.requests_sliced_at_end_date}}"
data-list-one="{{ data.requests.start_date_count }}"
data-list-two="{{ data.requests.end_date_count }}"
>
<h2>Chart: All requests</h2>
<p>{{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}</p>
<p>{{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}</p>
</canvas>
</div>
<div class="details-6 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for all requests</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="requests" %}
</div>
</details>
</div>
</div>
</div>

View file

@ -0,0 +1,26 @@
<table class="usa-table usa-table--borderless">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Start date {{ data.start_date }}</th>
<th scope="col">End date {{ data.end_date }} </th>
<tr>
</thead>
<tbody>
{% comment %}
This ugly notation is equivalent to data.property_name.start_date_count.index.
Or represented in the pure python way: data[property_name]["start_date_count"][index]
{% endcomment %}
{% with start_counts=data|get_item:property_name|get_item:"start_date_count" end_counts=data|get_item:property_name|get_item:"end_date_count" %}
{% for org_count_type in data.org_count_types %}
{% with index=forloop.counter %}
<tr>
<th class="padding-left-1" scope="row">{{ org_count_type }}</th>
<td class="padding-left-1">{{ start_counts|slice:index|last }}</td>
<td class="padding-left-1">{{ end_counts|slice:index|last }}</td>
</tr>
{% endwith %}
{% endfor %}
{% endwith %}
</tbody>
</table>

View file

@ -4,15 +4,12 @@
{% for app in app_list %}
<div class="app-{{ app.app_label }} module{% if app.app_url in request.path|urlencode %} current-app{% endif %}">
<table>
{# .gov override: add headers #}
{% if show_changelinks %}
<colgroup span="3"></colgroup>
{% else %}
<colgroup span="2"></colgroup>
{% endif %}
{# .gov override: display the app name as a caption rather than a table header #}
<caption class="text-bold">{{ app.name }}</caption>
<thead>
<tr>
{# .gov override: hide headers #}
{% comment %}
{% if show_changelinks %}
<th colspan="3" class="primary-th" scope="colgroup">
{{ app.name }}
@ -22,6 +19,7 @@
{{ app.name }}
</th>
{% endif %}
{% endcomment %}
</tr>
<tr>
<th scope="col">Model</th>
@ -45,16 +43,17 @@
{% endif %}
{% if model.add_url %}
<td><a href="{{ model.add_url }}" class="addlink">{% translate 'Add' %}</a></td>
{% comment %} Remove the 's' from the end of the string to avoid text like "Add domain requests" {% endcomment %}
<td><a href="{{ model.add_url }}" class="addlink" aria-label="Add {{ model.name|slice:":-1" }}">{% translate 'Add' %}</a></td>
{% else %}
<td></td>
{% endif %}
{% if model.admin_url and show_changelinks %}
{% if model.view_only %}
<td><a href="{{ model.admin_url }}" class="viewlink">{% translate 'View' %}</a></td>
<td><a href="{{ model.admin_url }}" class="viewlink" aria-label="View {{ model.name }}">{% translate 'View' %}</a></td>
{% else %}
<td><a href="{{ model.admin_url }}" class="changelink">{% translate 'Change' %}</a></td>
<td><a href="{{ model.admin_url }}" class="changelink" aria-label="Change {{ model.name }}">{% translate 'Change' %}</a></td>
{% endif %}
{% elif show_changelinks %}
<td></td>
@ -64,9 +63,20 @@
</table>
</div>
{% endfor %}
<div class="module module--custom">
<h2>Analytics</h2>
<a class="display-block padding-y-1 padding-x-1" href="{% url 'analytics' %}">Dashboard</a>
<div class="module">
<table class="width-full">
<caption class="text-bold">Analytics</caption>
<thead>
<tr>
<th scope="col">Reports</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><a href="{% url 'analytics' %}">Dashboard</a></th>
</tr>
</tbody>
</table>
</div>
{% else %}
<p>{% translate 'You dont have permission to view or edit anything.' %}</p>

View file

@ -22,7 +22,6 @@
<script src="{% static 'js/uswds.min.js' %}" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script type="application/javascript" src="{% static 'js/getgov-admin.min.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script>
{% endblock %}
@ -35,7 +34,7 @@
{% else %}
{{ title }} |
{% endif %}
{{ site_title|default:_('Django site admin') }}
Django admin
{% endblock %}
{% block extrastyle %}{{ block.super }}
@ -49,6 +48,10 @@
{% endwith %}
{% endif %}
{% if opts.model_name %}
<a class="usa-skipnav" href="#changelist-filter" aria-label="Skip to the filters section">Skip to filters</a>
{% endif %}
{# Djando update: this div will change to header #}
<div id="header">
<div id="branding">

View file

@ -7,29 +7,43 @@
{% if has_absolute_url %}
<ul>
<li>
<a href="{% add_preserved_filters history_url %}" class="historylink">{% translate "History" %}</a>
<button data-href="{% add_preserved_filters history_url %}" class="historylink use-button-as-link">{% translate "History" %}</button>
</li>
<li>
<a href="{{ absolute_url }}" class="viewsitelink">{% translate "View on site" %}</a>
<button data-href="{{ absolute_url }}" class="viewsitelink use-button-as-link">{% translate "View on site" %}</button>
</li>
</ul>
{% else %}
<ul>
{% if opts.model_name == 'domaininvitation' %}
{% if invitation.status == invitation.DomainInvitationStatus.INVITED %}
<li>
<a href="{% add_preserved_filters history_url %}">{% translate "History" %}</a>
<form method="post">
{% csrf_token %}
<input type="hidden" name="cancel_invitation" value="true">
<button type="submit" class="usa-button--dja">
Cancel invitation
</button>
</form>
</li>
{% endif %}
{% endif %}
<li>
<button data-href="{% add_preserved_filters history_url %}" class="historylink use-button-as-link">{% translate "History" %}</button>
</li>
{% if opts.model_name == 'domainrequest' %}
<li>
<a id="id-copy-to-clipboard-summary" class="usa-button--dja" type="button" href="#">
<svg class="usa-icon" >
<button id="id-copy-to-clipboard-summary" class="usa-button--dja">
<svg class="usa-icon">
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<!-- the span is targeted in JS, do not remove -->
<span>{% translate "Copy request summary" %}</span>
</a>
</button>
</li>
{% endif %}
</ul>
{% endif %}
{% endblock %}

View file

@ -1,4 +1,5 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls static admin_list %}
{% block content_title %}
<h1>{{ title }}</h1>
@ -37,6 +38,7 @@
for {{ search_query }}
{% endif %}
</h2>
{% endblock %}
{% comment %} Replace the Django ul markup with a div. We'll replace the li with a p in change_list_object_tools {% endcomment %}
@ -47,3 +49,24 @@
{% endblock %}
</div>
{% endblock %}
{% comment %} Replace the Django header markup for clearing all filters with a div. {% endcomment %}
{% block filters %}
{% if cl.has_filters %}
<nav id="changelist-filter" aria-labelledby="changelist-filter-header">
<h2 id="changelist-filter-header">{% translate 'Filter' %}</h2>
{% if cl.is_facets_optional or cl.has_active_filters %}<div id="changelist-filter-extra-actions">
{% if cl.is_facets_optional %}<h3>
{% if cl.add_facets %}<a href="{{ cl.remove_facet_link }}" class="hidelink">{% translate "Hide counts" %}</a>
{% else %}<a href="{{ cl.add_facet_link }}" class="viewlink">{% translate "Show counts" %}</a>{% endif %}
</h3>{% endif %}
{% if cl.has_active_filters %}<div class="margin-2">
<a href="{{ cl.clear_all_filters_qs }}" role="link">&#10006; {% translate "Clear all filters" %}</a>
</div>{% endif %}
</div>{% endif %}
{% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
</nav>
{% endif %}
{% endblock %}

View file

@ -5,9 +5,9 @@
{% if has_add_permission %}
<p class="margin-0 padding-0">
{% url cl.opts|admin_urlname:'add' as add_url %}
<a href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink">
<button data-href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink use-button-as-link">
{% blocktranslate with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktranslate %}
</a>
</button>
</p>
{% endif %}
{% endblock %}

View file

@ -19,11 +19,11 @@ Load our custom filters to extract info from the django generated markup.
{% if results.0|contains_checkbox %}
{# .gov - hardcode the select all checkbox #}
<th scope="col" class="action-checkbox-column" title="Toggle all">
<th scope="col" class="action-checkbox-column" title="Toggle">
<div class="text">
<span>
<input type="checkbox" id="action-toggle">
<label for="action-toggle" class="usa-sr-only">Toggle all</label>
<input type="checkbox" id="action-toggle">
</span>
</div>
<div class="clear"></div>
@ -34,9 +34,9 @@ Load our custom filters to extract info from the django generated markup.
{% if header.sortable %}
{% if header.sort_priority > 0 %}
<div class="sortoptions">
<a class="sortremove" href="{{ header.url_remove }}" title="{% translate "Remove from sorting" %}"></a>
<a class="sortremove" href="{{ header.url_remove }}" aria-label="{{ header.text }}" title="{% translate "Remove from sorting" %}"></a>
{% if num_sorted_fields > 1 %}<span class="sortpriority" title="{% blocktranslate with priority_number=header.sort_priority %}Sorting priority: {{ priority_number }}{% endblocktranslate %}">{{ header.sort_priority }}</span>{% endif %}
<a href="{{ header.url_toggle }}" class="toggle {% if header.ascending %}ascending{% else %}descending{% endif %}" title="{% translate "Toggle sorting" %}"></a>
<a href="{{ header.url_toggle }}" aria-label="{{ header.text }} sorting {% if header.ascending %}ascending{% else %}descending{% endif %}" class="toggle {% if header.ascending %}ascending{% else %}descending{% endif %}" title="{% translate "Toggle sorting" %}"></a>
</div>
{% endif %}
{% endif %}
@ -61,10 +61,10 @@ Load our custom filters to extract info from the django generated markup.
{% endif %}
<tr>
{% with result_value=result.0|extract_value %}
{% with result_label=result.1|extract_a_text %}
{% with result_label=result.1|extract_a_text checkbox_id="select-"|add:result_value %}
<td>
<input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ result_value|default:'value' }}-{{ result_label|default:'label' }}" class="action-select">
<label class="usa-sr-only" for="{{ result_value|default:'value' }}-{{ result_label|default:'label' }}">{{ result_label|default:'label' }}</label>
<label class="usa-sr-only" for="{{ checkbox_id }}">Select row {{ result_label|default:'label' }}</label>
<input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ checkbox_id }}" class="action-select">
</td>
{% endwith %}
{% endwith %}

View file

@ -0,0 +1,7 @@
{% load i18n %}
{% load admin_urls %}
{% if has_export_permission %}
{% comment %} Uses the initButtonLinks {% endcomment %}
<li><button class="export_link use-button-as-link" data-href="{% url opts|admin_urlname:"export" %}">{% trans "Export" %}</button></li>
{% endif %}

View file

@ -3,6 +3,6 @@
{% if has_import_permission %}
{% if not IS_PRODUCTION %}
<li><a href='{% url opts|admin_urlname:"import" %}' class="import_link">{% trans "Import" %}</a></li>
<li><button class="import_link use-button-as-link" data-href="{% url opts|admin_urlname:"import" %}">{% trans "Import" %}</button></li>
{% endif %}
{% endif %}

View file

@ -0,0 +1,26 @@
{% comment %} This is an override of the django search bar to add better accessibility compliance.
There are no blocks defined here, so we had to copy the code.
https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/search_form.html
{% endcomment %}
{% load i18n static %}
{% if cl.search_fields %}
<div id="toolbar"><form id="changelist-search" method="get" role="search">
<div><!-- DIV needed for valid HTML -->
{% comment %} .gov override - removed for="searchbar" {% endcomment %}
<label><img src="{% static "admin/img/search.svg" %}" alt="Search"></label>
<input type="text" size="40" name="{{ search_var }}" value="{{ cl.query }}" id="searchbar"{% if cl.search_help_text %} aria-describedby="searchbar_helptext"{% endif %}>
<input type="submit" value="{% translate 'Search' %}">
{% if show_result_count %}
<span class="small quiet">{% blocktranslate count counter=cl.result_count %}{{ counter }} result{% plural %}{{ counter }} results{% endblocktranslate %} (<a href="?{% if cl.is_popup %}{{ is_popup_var }}=1{% if cl.add_facets %}&{% endif %}{% endif %}{% if cl.add_facets %}{{ is_facets_var }}{% endif %}">{% if cl.show_full_result_count %}{% blocktranslate with full_result_count=cl.full_result_count %}{{ full_result_count }} total{% endblocktranslate %}{% else %}{% translate "Show all" %}{% endif %}</a>)</span>
{% endif %}
{% for pair in cl.params.items %}
{% if pair.0 != search_var %}<input type="hidden" name="{{ pair.0 }}" value="{{ pair.1 }}">{% endif %}
{% endfor %}
</div>
{% if cl.search_help_text %}
<br class="clear">
{% comment %} .gov override - added for="searchbar" {% endcomment %}
<label class="help" id="searchbar_helptext" for="searchbar">{{ cl.search_help_text }}</label>
{% endif %}
</form></div>
{% endif %}

View file

@ -10,7 +10,6 @@
<th>Title</th>
<th>Email</th>
<th>Phone</th>
<th>Roles</th>
<th>Action</th>
</tr>
</thead>
@ -28,11 +27,6 @@
{% endif %}
</td>
<td>{{ member.user.phone }}</td>
<td>
{% for role in member.user|portfolio_role_summary:original %}
<span class="usa-tag bg-primary-dark text-semibold">{{ role }}</span>
{% endfor %}
</td>
<td class="padding-left-1 text-size-small">
{% if member.user.email %}
<input aria-hidden="true" class="display-none" value="{{ member.user.email }}" />

View file

@ -1,21 +1,24 @@
{% load i18n %}
{% load static field_helpers url_helpers %}
<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
<ul class="mulitple-choice">
{% for choice in choices %}
<details data-filter-title="{{ title }}" open="">
<summary aria-label="Show/hide {{ title }} filters" role="button">
{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}
</summary>
<ul class="mulitple-choice">
{% for choice in choices %}
{% if choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}">
<a href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a>
</li>
{% endif %}
{% endfor %}
{% for choice in choices %}
{% endfor %}
{% for choice in choices %}
{% if not choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}">
{% if choice.selected and choice.exclude_query_string %}
<a class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
<a role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>
@ -25,7 +28,7 @@
</a>
{% endif %}
{% if not choice.selected and choice.include_query_string %}
<a class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
<a role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>
@ -33,5 +36,6 @@
{% endif %}
</li>
{% endif %}
{% endfor %}
</ul>
{% endfor %}
</ul>
</details>

View file

@ -0,0 +1,17 @@
{% extends "admin/delete_confirmation.html" %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the portfolio invitation here, it won't trigger any emails. It also won't remove the user's
portfolio access if they already logged in. Go to the
<a href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions
</a>
table if you want to remove the user from a portfolio.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "admin/delete_confirmation.html" %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a portfolio here, it will not send any emails when you click "Save".
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -21,7 +21,7 @@
{% if field and field.field and field.field.descriptions %}
{% with description=field.field.descriptions|get_dict_value:option.value %}
{% if description %}
<p class="margin-0 margin-top-1 font-body-2xs">{{ description }}</p>
<p class="margin-0 font-body-2xs">{{ description }}</p>
{% endif %}
{% endwith %}
{% endif %}

View file

@ -16,10 +16,10 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-users' pk=domain.id %}" class="usa-breadcrumb__link"><span>Domain managers</span></a>
<a href="{% url 'domain-users' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>Domain managers</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Add a domain manager</span>
@ -27,7 +27,7 @@
</ol>
</nav>
{% else %}
{% url 'domain-users' pk=domain.id as url %}
{% url 'domain-users' domain_pk=domain.id as url %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain manager breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
@ -42,17 +42,18 @@
{% endblock breadcrumb %}
<h1>Add a domain manager</h1>
{% if has_organization_feature_flag %}
{% if portfolio %}
<p>
You can add another user to help manage your domain. Users can only be a member of one .gov organization,
and they'll need to sign in with their Login.gov account.
Provide an email address for the domain manager youd like to add.
Theyll need to access the registrar using a Login.gov account thats associated with this email address.
Domain managers can be a member of only one .gov organization.
</p>
{% else %}
{% else %}
<p>
You can add another user to help manage your domain. They will need to sign in to the .gov registrar with
their Login.gov account.
Provide an email address for the domain manager youd like to add.
Theyll need to access the registrar using a Login.gov account thats associated with this email address.
</p>
{% endif %}
{% endif %}
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}

View file

@ -35,7 +35,7 @@
{# UNKNOWN domains would not have an expiration date and thus would show 'Expired' #}
{% if domain.is_expired and domain.state != domain.State.UNKNOWN %}
Expired
{% elif has_domain_renewal_flag and domain.is_expiring %}
{% elif domain.is_expiring %}
Expiring soon
{% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %}
DNS needed
@ -46,17 +46,17 @@
{% if domain.get_state_help_text %}
<p class="margin-y-0 text-primary-darker">
{% if has_domain_renewal_flag and domain.is_expired and is_domain_manager %}
{% if domain.is_expired and is_domain_manager %}
This domain has expired, but it is still online.
{% url 'domain-renewal' pk=domain.id as url %}
{% url 'domain-renewal' domain_pk=domain.id as url %}
<a href="{{ url }}" class="usa-link">Renew to maintain access.</a>
{% elif has_domain_renewal_flag and domain.is_expiring and is_domain_manager %}
{% elif domain.is_expiring and is_domain_manager %}
This domain will expire soon.
{% url 'domain-renewal' pk=domain.id as url %}
{% url 'domain-renewal' domain_pk=domain.id as url %}
<a href="{{ url }}" class="usa-link">Renew to maintain access.</a>
{% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %}
{% elif domain.is_expiring and is_portfolio_user %}
This domain will expire soon. Contact one of the listed domain managers to renew the domain.
{% elif has_domain_renewal_flag and domain.is_expired and is_portfolio_user %}
{% elif domain.is_expired and is_portfolio_user %}
This domain has expired, but it is still online. Contact one of the listed domain managers to renew the domain.
{% else %}
{{ domain.get_state_help_text }}
@ -82,7 +82,7 @@
{% endif %}
{% url 'domain-dns-nameservers' pk=domain.id as url %}
{% url 'domain-dns-nameservers' domain_pk=domain.id as url %}
{% if domain.nameservers|length > 0 %}
{% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=is_editable %}
{% else %}
@ -95,7 +95,7 @@
{% endif %}
{% endif %}
{% url 'domain-dns-dnssec' pk=domain.id as url %}
{% url 'domain-dns-dnssec' domain_pk=domain.id as url %}
{% if domain.dnssecdata is not None %}
{% include "includes/summary_item.html" with title='DNSSEC' value='Enabled' edit_link=url editable=is_editable %}
{% else %}
@ -104,26 +104,26 @@
{% if portfolio %}
{% if has_any_domains_portfolio_permission and has_edit_portfolio_permission %}
{% url 'domain-suborganization' pk=domain.id as url %}
{% url 'domain-suborganization' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_portfolio_permission %}
{% elif has_any_domains_portfolio_permission and has_view_portfolio_permission %}
{% url 'domain-suborganization' pk=domain.id as url %}
{% url 'domain-suborganization' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_view_portfolio_permission view_button=True %}
{% endif %}
{% else %}
{% url 'domain-org-name-address' pk=domain.id as url %}
{% url 'domain-org-name-address' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Organization' value=domain.domain_info address='true' edit_link=url editable=is_editable %}
{% url 'domain-senior-official' pk=domain.id as url %}
{% url 'domain-senior-official' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=is_editable %}
{% endif %}
{% url 'domain-security-email' pk=domain.id as url %}
{% url 'domain-security-email' domain_pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%}
{% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=is_editable %}
{% else %}
{% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=is_editable %}
{% endif %}
{% url 'domain-users' pk=domain.id as url %}
{% url 'domain-users' domain_pk=domain.id as url %}
{% if portfolio %}
{% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %}
{% else %}

View file

@ -13,7 +13,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DNS</span>
@ -30,14 +30,14 @@
<p>You can enter your name servers, as well as other DNS-related information, in the following sections:</p>
{% url 'domain-dns-nameservers' pk=domain.id as url %}
{% url 'domain-dns-nameservers' domain_pk=domain.id as url %}
<ul class="usa-list">
<li><a href="{{ url }}">Name servers</a></li>
{% url 'domain-dns-dnssec' pk=domain.id as url %}
{% url 'domain-dns-dnssec' domain_pk=domain.id as url %}
<li><a href="{{ url }}">DNSSEC</a></li>
{% if dns_prototype_flag and is_valid_domain %}
<li><a href="{% url 'prototype-domain-dns' pk=domain.id %}">Prototype DNS record creator</a></li>
<li><a href="{% url 'prototype-domain-dns' domain_pk=domain.id %}">Prototype DNS record creator</a></li>
{% endif %}
</ul>

View file

@ -14,10 +14,10 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
<a href="{% url 'domain-dns' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DNSSEC</span>
@ -69,7 +69,7 @@
<p class="margin-y-0">It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.</p>
</div>
</div>
<a href="{% url 'domain-dns-dnssec-dsdata' pk=domain.id %}" class="usa-button">Enable DNSSEC</a>
<a href="{% url 'domain-dns-dnssec-dsdata' domain_pk=domain.id %}" class="usa-button">Enable DNSSEC</a>
</div>
{% endif %}
</form>

View file

@ -18,13 +18,13 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
<a href="{% url 'domain-dns' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns-dnssec' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNSSEC</span></a>
<a href="{% url 'domain-dns-dnssec' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNSSEC</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DS data</span>

View file

@ -19,10 +19,10 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
<a href="{% url 'domain-dns' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DNS name servers</span>

View file

@ -29,7 +29,10 @@
{% csrf_token %}
{% if domain.domain_info.generic_org_type == 'federal' %}
{% input_with_errors form.federal_agency %}
<h4 class="margin-bottom-05">Federal Agency</h4>
<p class="margin-top-0">
{{ domain.domain_info.federal_agency }}
</p>
{% endif %}
{% input_with_errors form.organization_name %}

View file

@ -24,7 +24,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{domain.name}}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{domain.name}}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Renewal Form</span>
@ -63,14 +63,14 @@
{% endif %}
{% endif %}
{% url 'domain-security-email' pk=domain.id as url %}
{% url 'domain-security-email' domain_pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%}
{% include "includes/summary_item.html" with title='Security email' value=security_email custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
{% else %}
{% include "includes/summary_item.html" with title='Security email' value='None provided' custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
{% endif %}
{% url 'domain-users' pk=domain.id as url %}
{% url 'domain-users' domain_pk=domain.id as url %}
{% if portfolio %}
{% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %}
{% else %}
@ -91,7 +91,7 @@
Acknowledgement of .gov domain requirements </h3>
</legend>
<form method="post" action="{% url 'domain-renewal' pk=domain.id %}">
<form method="post" action="{% url 'domain-renewal' domain_pk=domain.id %}">
{% csrf_token %}
<div class="usa-checkbox">

View file

@ -20,7 +20,7 @@
{% endwith %}
{% endfor %}
<button type="submit" name="submit_button" value="save" class="usa-button usa-button--with-icon usa-button--unstyled">
<button id="submit-domain-request--site-button" type="submit" name="submit_button" value="save" class="usa-button usa-button--with-icon usa-button--unstyled">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another site</span>

View file

@ -36,7 +36,7 @@
</ol>
</nav>
{% elif steps.prev %}
<a href="{% namespaced_url 'domain-request' steps.prev id=domain_request_id %}" class="breadcrumb__back">
<a href="{% namespaced_url 'domain-request' steps.prev domain_request_pk=domain_request_id %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use>
</svg><span class="margin-left-05">Previous step</span>

View file

@ -15,7 +15,7 @@
</svg>
{% endif %}
{% endif %}
<a href="{% namespaced_url 'domain-request' this_step id=domain_request_id %}"
<a href="{% namespaced_url 'domain-request' this_step domain_request_pk=domain_request_id %}"
{% if this_step == steps.current %}
class="usa-current"
{% else %}

View file

@ -17,8 +17,8 @@
<p>If you withdraw your request, we won't review it. Once you withdraw your request, you can edit it and submit it again. </p>
<p><a href="{% url 'domain-request-withdrawn' DomainRequest.id %}" class="usa-button withdraw">Withdraw request</a>
<a href="{% url 'domain-request-status' DomainRequest.id %}">Cancel</a></p>
<p><a href="{% url 'domain-request-withdrawn' domain_request_pk=DomainRequest.id %}" class="usa-button withdraw">Withdraw request</a>
<a href="{% url 'domain-request-status' domain_request_pk=DomainRequest.id %}">Cancel</a></p>
</div>
</div>

View file

@ -16,7 +16,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Security email</span>

View file

@ -17,14 +17,14 @@
{% endif %}
<li class="usa-sidenav__item">
{% url 'domain-dns' pk=domain.id as url %}
{% url 'domain-dns' domain_pk=domain.id as url %}
<a href="{{ url }}" {% if request.path|startswith:url %}class="usa-current"{% endif %}">
DNS
</a>
{% if request.path|startswith:url %}
<ul class="usa-sidenav__sublist">
<li class="usa-sidenav__item">
{% url 'domain-dns-nameservers' pk=domain.id as url %}
{% url 'domain-dns-nameservers' domain_pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
@ -33,7 +33,7 @@
</li>
<li class="usa-sidenav__item">
{% url 'domain-dns-dnssec' pk=domain.id as url %}
{% url 'domain-dns-dnssec' domain_pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path|startswith:url %}class="usa-current"{% endif %}
>
@ -43,7 +43,7 @@
{% if domain.dnssecdata is not None or request.path|startswith:url and request.path|endswith:'dsdata' %}
<ul class="usa-sidenav__sublist">
<li class="usa-sidenav__item">
{% url 'domain-dns-dnssec-dsdata' pk=domain.id as url %}
{% url 'domain-dns-dnssec-dsdata' domain_pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
@ -81,7 +81,7 @@
{% endwith %}
{% if has_domain_renewal_flag and is_domain_manager%}
{% if is_domain_manager%}
{% if domain.is_expiring or domain.is_expired %}
{% with url_name="domain-renewal" %}
{% include "includes/domain_sidenav_item.html" with item_text="Renewal form" %}

View file

@ -16,7 +16,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Suborganization</span>

View file

@ -13,7 +13,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Domain managers</span>
@ -25,29 +25,25 @@
<h1>Domain managers</h1>
{% comment %}Copy below differs depending on whether view is in portfolio mode.{% endcomment %}
{% if not portfolio %}
<p>
Domain managers can update all information related to a domain within the
.gov registrar, including security email and DNS name servers.
Domain managers can update information related to this domain, including security email and DNS name servers.
</p>
{% else %}
<p>
Domain managers can update all information related to a domain within the
.gov registrar, including contact details, senior official, security email, and DNS name servers.
</p>
{% endif %}
<ul class="usa-list">
<li>There is no limit to the number of domain managers you can add.</li>
<li>After adding a domain manager, an email invitation will be sent to that user with
instructions on how to set up an account.</li>
<li>There is no limit on the number of domain managers you can add.</li>
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
{% if not portfolio %}<li>All domain managers will be notified when updates are made to this domain.</li>{% endif %}
<li>Domains must have at least one domain manager. You cant remove yourself as a domain manager if youre the only one assigned to this domain.
{% if portfolio %} Add another domain manager before you remove yourself from this domain.{% endif %}</li>
<li>All domain managers will be notified when updates are made to this domain and when managers are added or removed.</li>
<li>Domains must have at least one manager. You cant remove yourself if youre the only one assigned to this domain.</li>
</ul>
{% if domain_manager_roles and domain_manager_roles|length == 1 %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body">
This domain has only one manager. Consider adding another manager to ensure the domain has continuous oversight and support.
</div>
</div>
{% endif %}
{% if domain_manager_roles %}
<section class="section-outlined" id="domain-managers">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
@ -93,7 +89,7 @@
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button_id="user-delete-button-"|add:counter_str|safe modal_button_text="Yes, remove myself" modal_button_class="usa-button--secondary" %}
{% endwith %}
</div>
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}" >
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" domain_pk=domain.id user_pk=item.permission.user.id %}" >
{% csrf_token %}
</form>
{% else %}
@ -108,7 +104,7 @@
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description="<strong>"|add:email|add:"</strong> will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button_id="user-delete-button-"|add:counter_str|safe modal_button_text="Yes, remove domain manager" modal_button_class="usa-button--secondary" %}
{% endwith %}
</div>
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}">
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" domain_pk=domain.id user_pk=item.permission.user.id %}">
{% csrf_token %}
</form>
{% endif %}
@ -123,7 +119,7 @@
></div>
{% endif %}
<a class="usa-button usa-button--unstyled usa-button--with-icon" href="{% url 'domain-users-add' pk=domain.id %}">
<a class="usa-button usa-button--unstyled usa-button--with-icon" href="{% url 'domain-users-add' domain_pk=domain.id %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add a domain manager</span>
@ -154,7 +150,7 @@
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
<td>
{% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
<form method="POST" action="{% url "invitation-cancel" pk=invitation.domain_invitation.id %}">
<form method="POST" action="{% url "invitation-cancel" domain_invitation_pk=invitation.domain_invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
</form>
{% endif %}

View file

@ -17,7 +17,7 @@ Domains should uniquely identify a government organization and be clear to the g
ACTION NEEDED
First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. <https://manage.get.gov/> Once you submit your updated request, well resume the adjudication process.
First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. <{{ manage_url }}> Once you submit your updated request, well resume the adjudication process.
If you have questions or want to discuss potential domain names, reply to this email.

View file

@ -21,7 +21,7 @@ We expect a senior official to be someone in a role of significant, executive re
ACTION NEEDED
Reply to this email with a justification for naming {{ domain_request.senior_official.get_formatted_name }} as the senior official. If you have questions or comments, include those in your reply.
Alternatively, you can log in to the registrar and enter a different senior official for this domain request. <https://manage.get.gov/> Once you submit your updated request, well resume the adjudication process.
Alternatively, you can log in to the registrar and enter a different senior official for this domain request. <{{ manage_url }}> Once you submit your updated request, well resume the adjudication process.
THANK YOU

View file

@ -4,11 +4,10 @@ Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first
{{ requestor_email }} has invited you to manage:
{% for domain in domains %}{{ domain.name }}
{% endfor %}
To manage domain information, visit the .gov registrar <https://manage.get.gov>.
To manage domain information, visit the .gov registrar <{{ manage_url }}>.
----------------------------------------------------------------
{% if not requested_user %}
YOU NEED A LOGIN.GOV ACCOUNT
Youll need a Login.gov account to access the .gov registrar. That account needs to be
associated with the following email address: {{ invitee_email_address }}
@ -17,8 +16,6 @@ Login.gov provides a simple and secure process for signing in to many government
services with one account. If you dont already have one, follow these steps to create
your Login.gov account <https://login.gov/help/get-started/create-your-account/>.
{% endif %}
DOMAIN MANAGEMENT
As a .gov domain manager, you can add or update information like name servers. Youll
also serve as a contact for the domains you manage. Please keep your contact

View file

@ -11,6 +11,7 @@ MANAGER REMOVED: {{ manager_removed.email }}
WHY DID YOU RECEIVE THIS EMAIL?
Youre listed as a domain manager for {{ domain.name }}, so youll receive a notification whenever a domain manager is removed from that domain.
If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email.
THANK YOU

View file

@ -15,7 +15,7 @@ The person who received the invitation will become a domain manager once they lo
associated with the invited email address.
If you need to cancel this invitation or remove the domain manager, you can do that by going to
this domain in the .gov registrar <https://manage.get.gov/>.
this domain in the .gov registrar <{{ manage_url }}>.
WHY DID YOU RECEIVE THIS EMAIL?

View file

@ -11,7 +11,7 @@ STATUS: Withdrawn
----------------------------------------------------------------
YOU CAN EDIT YOUR WITHDRAWN REQUEST
You can edit and resubmit this request by signing in to the registrar <https://manage.get.gov/>.
You can edit and resubmit this request by signing in to the registrar <{{ manage_url }}>.
SOMETHING WRONG?

View file

@ -16,7 +16,7 @@ The person who received the invitation will become an admin once they log in to
associated with the invited email address.
If you need to cancel this invitation or remove the admin, you can do that by going to
the Members section for your organization <https://manage.get.gov/>.
the Members section for your organization <{{ manage_url }}>.
WHY DID YOU RECEIVE THIS EMAIL?

View file

@ -8,7 +8,7 @@ REMOVED BY: {{ requestor_email }}
REMOVED ON: {{date}}
ADMIN REMOVED: {{ removed_email_address }}
You can view this update by going to the Members section for your .gov organization <https://manage.get.gov/>.
You can view this update by going to the Members section for your .gov organization <{{ manage_url }}>.
----------------------------------------------------------------

View file

@ -3,7 +3,7 @@ Hi.
{{ requestor_email }} has invited you to {{ portfolio.organization_name }}.
You can view this organization on the .gov registrar <https://manage.get.gov>.
You can view this organization on the .gov registrar <{{ manage_url }}>.
----------------------------------------------------------------

View file

@ -0,0 +1,21 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
{{ requestor_email }} has removed you from {{ portfolio.organization_name }}.
You can no longer view this organization or its related domains within the .gov registrar.
SOMETHING WRONG?
If you have questions or concerns, reach out to the person who removed you from the
organization, or reply to this email.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
You've been removed from a .gov organization

View file

@ -0,0 +1,35 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
Your permissions were updated in the .gov registrar.
ORGANIZATION: {{ portfolio.organization_name }}
UPDATED BY: {{ requestor_email }}
UPDATED ON: {{ date }}
YOUR PERMISSIONS: {{ permissions.role_display }}
Domains - {{ permissions.domains_display }}
Domain requests - {{ permissions.domain_requests_display }}
Members - {{ permissions.members_display }}
Your updated permissions are now active in the .gov registrar <https://manage.get.gov>.
----------------------------------------------------------------
SOMETHING WRONG?
If you have questions or concerns, reach out to the person who updated your
permissions, or reply to this email.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov
domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
(CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
Your permissions were updated in the .gov registrar

View file

@ -8,7 +8,7 @@ REQUESTED BY: {{ domain_request.creator.email }}
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Approved
You can manage your approved domain on the .gov registrar <https://manage.get.gov>.
You can manage your approved domain on the .gov registrar <{{ manage_url }}>.
----------------------------------------------------------------

View file

@ -68,10 +68,12 @@ Learn more about:
NEED ASSISTANCE?
If you have questions about this domain request or need help choosing a new domain name, reply to this email.
{% endif %}
{% if reason != domain_request.RejectionReasons.REQUESTOR_NOT_ELIGIBLE and reason != domain_request.RejectionReasons.ORG_NOT_ELIGIBLE %}
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
{% endif %}
----------------------------------------------------------------
The .gov team

View file

@ -20,7 +20,7 @@ During our review, well verify that:
- You work at the organization and/or can make requests on its behalf
- Your requested domain meets our naming requirements
{% endif %}
Well email you if we have questions. Well also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. <https://manage.get.gov>.
Well email you if we have questions. Well also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. <{{ manage_url }}>.
NEED TO MAKE CHANGES?

View file

@ -31,7 +31,7 @@ CHECK YOUR .GOV DOMAIN CONTACTS
This is a good time to check who has access to your .gov domain{% if domains|length > 1 %}s{% endif %}. The admin, technical, and billing contacts listed for your domain{% if domains|length > 1 %}s{% endif %} in our old system also received this email. In our new registrar, these contacts are all considered “domain managers.” We no longer have the admin, technical, and billing roles, and you arent limited to three domain managers like in the old system.
1. Once you have your Login.gov account, sign in to the new registrar at <https://manage.get.gov>.
1. Once you have your Login.gov account, sign in to the new registrar at <{{ manage_url }}>.
2. Click the “Manage” link next to your .gov domain, then click on “Domain managers” to see who has access to your domain.
3. If any of these users should not have access to your domain, let us know in a reply to this email.
@ -57,7 +57,7 @@ THANK YOU
The .gov team
.Gov blog <https://get.gov/updates/>
Domain management <https://manage.get.gov>
Domain management <{{ manage_url }}}>
Get.gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>

View file

@ -8,7 +8,7 @@ UPDATED BY: {{user}}
UPDATED ON: {{date}}
INFORMATION UPDATED: {{changes}}
You can view this update in the .gov registrar <https://manage.get.gov/>.
You can view this update in the .gov registrar <{{ manage_url }}>.
Get help with managing your .gov domain <https://get.gov/help/domain-management/>.

View file

@ -163,7 +163,7 @@
</div>
{% endif %}
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domain-requests__table-wrapper">
<div class="display-none usa-table-container--scrollable usa-table-container--override-overflow margin-top-0" tabindex="0" id="domain-requests__table-wrapper">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<caption class="sr-only">Your domain requests</caption>
<thead>

View file

@ -1,6 +1,6 @@
<li class="usa-sidenav__item">
{% if url_name %}
{% url url_name pk=domain.id as url %}
{% url url_name domain_pk=domain.id as url %}
{% endif %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}

View file

@ -9,7 +9,7 @@
<span id="get_domains_json_url" class="display-none">{{url}}</span>
<!-- Org model banner (org manager can view, domain manager can edit) -->
{% if has_domain_renewal_flag and num_expiring_domains > 0 and has_any_domains_portfolio_permission %}
{% if num_expiring_domains > 0 and has_any_domains_portfolio_permission %}
<section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body">
@ -75,7 +75,7 @@
</div>
<!-- Non org model banner -->
{% if has_domain_renewal_flag and num_expiring_domains > 0 and not portfolio %}
{% if num_expiring_domains > 0 and not portfolio %}
<section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body">
@ -173,7 +173,6 @@
>Deleted</label
>
</div>
{% if has_domain_renewal_flag %}
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
@ -185,7 +184,6 @@
<label class="usa-checkbox__label" for="filter-status-expiring"
>Expiring soon</label>
</div>
{% endif %}
</fieldset>
</div>
</div>
@ -200,7 +198,7 @@
</svg>
</button>
</div>
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domains__table-wrapper">
<div class="display-none usa-table-container--scrollable usa-table-container--override-overflow margin-top-0" tabindex="0" id="domains__table-wrapper">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<caption class="sr-only">Your registered domains</caption>
<thead>

View file

@ -1,20 +1,20 @@
{% load field_helpers %}
<div id="member-basic-permissions" class="margin-top-2">
<h2>What permissions do you want to add?</h2>
<h2>Member permissions</h2>
<p>Configure the permissions for this member. Basic members cannot manage member permissions or organization metadata.</p>
<h3 class="margin-bottom-0">Domains <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %}
<h3 class="margin-bottom-neg-1 margin-top-4">Domains <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" label_classes="margin-top-1" %}
{% input_with_errors form.domain_permissions %}
{% endwith %}
<h3 class="margin-bottom-0">Domain requests <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %}
<h3 class="margin-bottom-neg-1 margin-top-2">Domain requests <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" label_classes="margin-top-1" %}
{% input_with_errors form.domain_request_permissions %}
{% endwith %}
<h3 class="margin-bottom-0">Members <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %}
<h3 class="margin-bottom-neg-1 margin-top-2">Members <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" label_classes="margin-top-1" %}
{% input_with_errors form.member_permissions %}
{% endwith %}
</div>

View file

@ -1,6 +1,6 @@
<h4 class="margin-bottom-0">Assigned domains</h4>
{% if domain_count > 0 %}
<h4 class="margin-bottom-0">Domains assigned</h4>
<p class="margin-top-0">{{domain_count}}</p>
{% else %}
<p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Manage".{% endif %}</p>
<p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Edit".{% endif %}</p>
{% endif %}

View file

@ -1,5 +1,3 @@
{% load static %}
{% if member %}
<span
id="portfolio-js-value"
@ -36,47 +34,9 @@
<div class="section-outlined__header margin-bottom-3 grid-row">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
<section aria-label="Member domains search component">
<form class="usa-search usa-search--show-label" method="POST" role="search">
{% csrf_token %}
<label class="usa-label display-block margin-bottom-05" for="edit-member-domains__search-field">
{% if has_edit_members_portfolio_permission %}
Search all domains
{% else %}
Search domains assigned to
{% if member %}
{{ member.email }}
{% else %}
{{ portfolio_invitation.email }}
{% endif %}
{% endif %}
</label>
<div class="usa-search--show-label__input-wrapper">
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="edit-member-domains__reset-search" 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
</button>
<input
class="usa-input"
id="edit-member-domains__search-field"
type="search"
name="member-domains-search"
/>
<button class="usa-button" type="submit" id="edit-member-domains__search-field-submit">
<span class="usa-search__submit-text">Search </span>
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</div>
</form>
</section>
</div>
{% with label_text="Search all domains" item_name="edit-member-domains" aria_label_text="Member domains search component" %}
{% include "includes/search.html" %}
{% endwith %}
</div>
<!-- ---------- MAIN TABLE ---------- -->
@ -85,7 +45,7 @@
<caption class="sr-only">member domains</caption>
<thead>
<tr>
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105"><span class="sr-only">Assigned domains</span></th>
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105 width-6 left-align-sort-button"><span class="sr-only">Assigned domains</span></th>
<!-- We override default sort to be name/ascending in the JSON endpoint. We add the correct aria-sort attribute here to reflect that in the UI -->
<th data-sortable="name" scope="col" role="columnheader" aria-sort="descending">Domains</th>
</tr>
@ -100,7 +60,7 @@
></div>
</div>
<div class="display-none margin-bottom-4" id="edit-member-domains__no-data">
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p>
<p>This member does not manage any domains.</p>
</div>
<div class="display-none margin-bottom-4" id="edit-member-domains__no-search-results">
<p>No results found</p>

View file

@ -1,5 +1,3 @@
{% load static %}
{% if member %}
<span
id="portfolio-js-value"
@ -36,43 +34,17 @@
<div class="section-outlined__header margin-bottom-3 grid-row" id="edit-member-domains__search">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
<section aria-label="Member domains search component">
<form class="usa-search usa-search--show-label" method="POST" role="search">
{% csrf_token %}
<label class="usa-label display-block margin-bottom-05" for="member-domains__search-field">
Search domains assigned to
{% with label_text="Domains assigned to " %}
{% if member %}
{{ member.email }}
{% with label_text=label_text|add:member.email item_name="member-domains" aria_label_text="Member domains search component" %}
{% include "includes/search.html" %}
{% endwith %}
{% else %}
{{ portfolio_invitation.email }}
{% with label_text=label_text|add:portfolio_invitation.email item_name="member-domains" aria_label_text="Member domains search component" %}
{% include "includes/search.html" %}
{% endwith %}
{% endif %}
</label>
<div class="usa-search--show-label__input-wrapper">
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="member-domains__reset-search" 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
</button>
<input
class="usa-input"
id="member-domains__search-field"
type="search"
name="member-domains-search"
/>
<button class="usa-button" type="submit" id="member-domains__search-field-submit">
<span class="usa-search__submit-text">Search </span>
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</div>
</form>
</section>
</div>
{% endwith %}
</div>
<!-- ---------- MAIN TABLE ---------- -->
@ -95,7 +67,7 @@
></div>
</div>
<div class="display-none margin-bottom-4" id="member-domains__no-data">
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p>
<p>This member does not manage any domains.</p>
</div>
<div class="display-none margin-bottom-4" id="member-domains__no-search-results">
<p>No results found</p>

View file

@ -1,33 +1,11 @@
<h4 class="margin-bottom-0">Member access</h4>
{% if permissions.roles and 'organization_admin' in permissions.roles %}
<p class="margin-top-0">Admin</p>
{% elif permissions.roles and 'organization_member' in permissions.roles %}
<p class="margin-top-0">Basic</p>
{% else %}
<p class="margin-top-0"></p>
{% endif %}
<p class="margin-top-0">{{ permissions.role_display }}</p>
<h4 class="margin-bottom-0 text-primary">Domains</h4>
{% if member_has_view_all_domains_portfolio_permission %}
<p class="margin-top-0">Viewer, all</p>
{% else %}
<p class="margin-top-0">Viewer, limited</p>
{% endif %}
<p class="margin-top-0">{{ permissions.domains_display }}: {{ permissions.domains_description_display }}</p>
<h4 class="margin-bottom-0 text-primary">Domain requests</h4>
{% if member_has_edit_request_portfolio_permission %}
<p class="margin-top-0">Creator</p>
{% elif member_has_view_all_requests_portfolio_permission %}
<p class="margin-top-0">Viewer</p>
{% else %}
<p class="margin-top-0">No access</p>
{% endif %}
<p class="margin-top-0">{{ permissions.domain_requests_display }}: {{ permissions.domain_requests_description_display }}</p>
<h4 class="margin-bottom-0 text-primary">Members</h4>
{% if member_has_edit_members_portfolio_permission %}
<p class="margin-top-0">Manager</p>
{% elif member_has_view_members_portfolio_permission %}
<p class="margin-top-0">Viewer</p>
{% else %}
<p class="margin-top-0">No access</p>
{% endif %}
<p class="margin-top-0">{{ permissions.members_display }}: {{ permissions.members_description_display }}</p>

View file

@ -4,7 +4,7 @@
{% for step in steps %}
<section class="summary-item margin-top-3">
{% if is_editable %}
{% namespaced_url 'domain-request' step id=domain_request_id as domain_request_url %}
{% namespaced_url 'domain-request' step domain_request_pk=domain_request_id as domain_request_url %}
{% endif %}
{% if step == Step.REQUESTING_ENTITY %}

View file

@ -4,7 +4,7 @@
{% for step in steps %}
<section class="summary-item margin-top-3">
{% if is_editable %}
{% namespaced_url 'domain-request' step id=domain_request_id as domain_request_url %}
{% namespaced_url 'domain-request' step domain_request_pk=domain_request_id as domain_request_url %}
{% endif %}
{% if step == Step.ORGANIZATION_TYPE %}

View file

@ -114,7 +114,7 @@
{% block modify_request %}
{% if DomainRequest.is_withdrawable %}
<p><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
<p><a href="{% url 'domain-request-withdraw-confirmation' domain_request_pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a>
</p>
{% endif %}

Some files were not shown because too many files have changed in this diff Show more