mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-26 20:48:40 +02:00
Merge remote-tracking branch 'origin/main' into ms/3125-fix-sandbox-log-output
This commit is contained in:
commit
6987d9022c
225 changed files with 13089 additions and 7653 deletions
8
.github/workflows/security-check.yaml
vendored
8
.github/workflows/security-check.yaml
vendored
|
@ -2,17 +2,9 @@ name: Security checks
|
|||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
branches:
|
||||
- main
|
||||
|
||||
|
|
4
.github/workflows/test.yaml
vendored
4
.github/workflows/test.yaml
vendored
|
@ -3,10 +3,6 @@ name: Testing
|
|||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
|
|
@ -4,7 +4,6 @@ Pull requests should be titled in the format of `#issue_number: Descriptive name
|
|||
Pull requests including a migration should be suffixed with ` - MIGRATION`
|
||||
|
||||
After creating a pull request, pull request submitters should:
|
||||
- Add at least 2 developers as PR reviewers (only 1 will need to approve).
|
||||
- Message on Slack or in standup to notify the team that a PR is ready for review.
|
||||
- If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file.
|
||||
|
||||
|
|
17
docs/developer/cloning-databases.md
Normal file
17
docs/developer/cloning-databases.md
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Cloning Databases
|
||||
The clone-db workflow clones a Source database to a Destination database using cloud.gov's cg-manage-rds tool. This document contains additional information needed to understand how the workflow functions.
|
||||
|
||||
## Additional Roles Required
|
||||
The clone-db workflow functions by temporarily sharing the Destination database with the space of the Source database. This is because cloning databases across spaces is hard. Sharing is done via the `cf share-service` command, but requires that the authenticated user (in this case this will be a user from the Source space) have the `space-developer` role in *both* the Source and Destination spaces. This must be set by someone with permission to edit space roles *before* the workflow runs. The user in question can be found using the `cf space-users [ORG] [SPACE]` command where the SPACE is the Source space, and will appear as a UAA user with a UUID as the name. There is only one such user per space by default (this is a [service account](https://cloud.gov/docs/services/cloud-gov-service-account/) set up by cloud.gov for our Github workflows). This user needs to be provided with the `space-developer` role in the Destination space, which can be accomplished using `cf set-space-role [USER] [ORG] [DESTINATION SPACE] SpaceDeveloper`.
|
||||
|
||||
## Turning Off DB Cloning Fast (For Emergencies or other Scenarios)
|
||||
Note: In less urgent situations it may be better to make a PR removing the scheduled workflow trigger.
|
||||
|
||||
Step 1:
|
||||
Get the name of the correct service using `cf spaces-users cisa-dotgov stable`. There should only be one user with a name that is a UUID, that is the one you want.
|
||||
|
||||
step 2:
|
||||
Remove the space developer role by doing the following command:
|
||||
`cf unset-space-role [USER] cisa-dotgov staging SpaceDeveloper`
|
||||
|
||||
This will cause the job to fail without requiring pushing anything to main.
|
|
@ -103,3 +103,31 @@ response = registry._client.transport.receive()
|
|||
```
|
||||
|
||||
This is helpful for debugging situations where epplib is not correctly or fully parsing the XML returned from the registry.
|
||||
|
||||
### Adding in a expiring soon domain
|
||||
The below scenario is if you are NOT in org model mode (`organization_feature` waffle flag is off).
|
||||
|
||||
1. Go to the `staging` sandbox and to `/admin`
|
||||
2. Go to Domains and find a domain that is actually expired by sorting the Expiration Date column
|
||||
3. Click into the domain to check the expiration date
|
||||
4. Click into Manage Domain to double check the expiration date as well
|
||||
5. Now hold onto that domain name, and save it for the command below
|
||||
|
||||
6. In a terminal, run these commands:
|
||||
```
|
||||
cf ssh getgov-<your-intials>
|
||||
/tmp/lifecycle/shell
|
||||
./manage.py shell
|
||||
from registrar.models import Domain, DomainInvitation
|
||||
from registrar.models import User
|
||||
user = User.objects.filter(first_name="<your-first-name>")
|
||||
domain = Domain.objects.get_or_create(name="<that-domain-here>")
|
||||
```
|
||||
|
||||
7. Go back to `/admin` and create Domain Information for that domain you just added in via the terminal
|
||||
8. Go to Domain to find it
|
||||
9. Click Manage Domain
|
||||
10. Add yourself as domain manager
|
||||
11. Go to the Registrar page and you should now see the expiring domain
|
||||
|
||||
If you want to be in the org model mode, turn the `organization_feature` waffle flag on, and add that domain via Django Admin to a portfolio to be able to view it.
|
|
@ -908,13 +908,86 @@ Example (only requests): `./manage.py create_federal_portfolio --branch "executi
|
|||
|
||||
##### Parameters
|
||||
| | Parameter | Description |
|
||||
|:-:|:-------------------------- |:-------------------------------------------------------------------------------------------|
|
||||
|:-:|:---------------------------- |:-------------------------------------------------------------------------------------------|
|
||||
| 1 | **agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". |
|
||||
| 2 | **branch** | Creates a portfolio for each federal agency in a branch: executive, legislative, judicial |
|
||||
| 3 | **both** | If True, runs parse_requests and parse_domains. |
|
||||
| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. |
|
||||
| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. |
|
||||
| 6 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. |
|
||||
|
||||
- Parameters #1-#2: Either `--agency_name` or `--branch` must be specified. Not both.
|
||||
- Parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them,
|
||||
you must specify at least one to run this script.
|
||||
|
||||
|
||||
## Patch suborganizations
|
||||
This script deletes some duplicate suborganization data that exists in our database (one-time use).
|
||||
It works in two ways:
|
||||
1. If the only name difference between two suborg records is extra spaces or a capitalization difference,
|
||||
then we delete all duplicate records of this type.
|
||||
2. If the suborg name is one we manually specify to delete via the script.
|
||||
|
||||
Before it deletes records, it goes through each DomainInformation and DomainRequest object and updates the reference to "sub_organization" to match the non-duplicative record.
|
||||
|
||||
### Running on sandboxes
|
||||
|
||||
#### Step 1: Login to CloudFoundry
|
||||
```cf login -a api.fr.cloud.gov --sso```
|
||||
|
||||
#### Step 2: SSH into your environment
|
||||
```cf ssh getgov-{space}```
|
||||
|
||||
Example: `cf ssh getgov-za`
|
||||
|
||||
#### Step 3: Create a shell instance
|
||||
```/tmp/lifecycle/shell```
|
||||
|
||||
#### Step 4: Upload your csv to the desired sandbox
|
||||
[Follow these steps](#use-scp-to-transfer-data-to-sandboxes) to upload the federal_cio csv to a sandbox of your choice.
|
||||
|
||||
#### Step 5: Running the script
|
||||
To create a specific portfolio:
|
||||
```./manage.py patch_suborganizations```
|
||||
|
||||
### Running locally
|
||||
|
||||
#### Step 1: Running the script
|
||||
```docker-compose exec app ./manage.py patch_suborganizations```
|
||||
|
||||
|
||||
## Remove Non-whitelisted Portfolios
|
||||
This script removes Portfolio entries from the database that are not part of a predefined list of allowed portfolios (`ALLOWED_PORTFOLIOS`).
|
||||
It performs the following actions:
|
||||
1. Prompts the user for confirmation before proceeding with deletions.
|
||||
2. Updates related objects such as `DomainInformation`, `Domain`, and `DomainRequest` to set their `portfolio` field to `None` to prevent integrity errors.
|
||||
3. Deletes associated objects such as `PortfolioInvitation`, `UserPortfolioPermission`, and `Suborganization`.
|
||||
4. Logs a detailed summary of all cascading deletions and orphaned objects.
|
||||
|
||||
### Running on sandboxes
|
||||
|
||||
#### Step 1: Login to CloudFoundry
|
||||
```cf login -a api.fr.cloud.gov --sso```
|
||||
|
||||
#### Step 2: SSH into your environment
|
||||
```cf ssh getgov-{space}```
|
||||
|
||||
Example: `cf ssh getgov-nl`
|
||||
|
||||
#### Step 3: Create a shell instance
|
||||
```/tmp/lifecycle/shell```
|
||||
|
||||
#### Step 4: Running the script
|
||||
To remove portfolios:
|
||||
```./manage.py remove_unused_portfolios```
|
||||
|
||||
If you wish to enable debug mode for additional logging:
|
||||
```./manage.py remove_unused_portfolios --debug```
|
||||
|
||||
### Running locally
|
||||
|
||||
#### Step 1: Running the script
|
||||
```docker-compose exec app ./manage.py remove_unused_portfolios```
|
||||
|
||||
To enable debug mode locally:
|
||||
```docker-compose exec app ./manage.py remove_unused_portfolios --debug```
|
|
@ -4,7 +4,7 @@ verify_ssl = true
|
|||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
django = "4.2.10"
|
||||
django = "4.2.17"
|
||||
cfenv = "*"
|
||||
django-cors-headers = "*"
|
||||
pycryptodomex = "*"
|
||||
|
|
1318
src/Pipfile.lock
generated
1318
src/Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -21,48 +21,65 @@ class OpenIdConnectBackend(ModelBackend):
|
|||
"""
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
logger.debug("kwargs %s" % kwargs)
|
||||
user = None
|
||||
if not kwargs or "sub" not in kwargs.keys():
|
||||
return user
|
||||
logger.debug("kwargs %s", kwargs)
|
||||
|
||||
if not kwargs or "sub" not in kwargs:
|
||||
return None
|
||||
|
||||
UserModel = get_user_model()
|
||||
username = self.clean_username(kwargs["sub"])
|
||||
openid_data = self.extract_openid_data(kwargs)
|
||||
|
||||
# Some OP may actually choose to withhold some information, so we must
|
||||
# test if it is present
|
||||
openid_data = {"last_login": timezone.now()}
|
||||
openid_data["first_name"] = kwargs.get("given_name", "")
|
||||
openid_data["last_name"] = kwargs.get("family_name", "")
|
||||
openid_data["email"] = kwargs.get("email", "")
|
||||
openid_data["phone"] = kwargs.get("phone", "")
|
||||
|
||||
# Note that this could be accomplished in one try-except clause, but
|
||||
# instead we use get_or_create when creating unknown users since it has
|
||||
# built-in safeguards for multiple threads.
|
||||
if getattr(settings, "OIDC_CREATE_UNKNOWN_USER", True):
|
||||
args = {
|
||||
UserModel.USERNAME_FIELD: username,
|
||||
# defaults _will_ be updated, these are not fallbacks
|
||||
"defaults": openid_data,
|
||||
user = self.get_or_create_user(UserModel, username, openid_data, kwargs)
|
||||
else:
|
||||
user = self.get_user_by_username(UserModel, username)
|
||||
|
||||
if user:
|
||||
user.on_each_login()
|
||||
|
||||
return user
|
||||
|
||||
def extract_openid_data(self, kwargs):
|
||||
"""Extract OpenID data from authentication kwargs."""
|
||||
return {
|
||||
"last_login": timezone.now(),
|
||||
"first_name": kwargs.get("given_name", ""),
|
||||
"last_name": kwargs.get("family_name", ""),
|
||||
"email": kwargs.get("email", ""),
|
||||
"phone": kwargs.get("phone", ""),
|
||||
}
|
||||
|
||||
user, created = UserModel.objects.get_or_create(**args)
|
||||
def get_or_create_user(self, UserModel, username, openid_data, kwargs):
|
||||
"""Retrieve user by username or email, or create a new user."""
|
||||
user = self.get_user_by_username(UserModel, username)
|
||||
|
||||
if not created:
|
||||
# If user exists, update existing user
|
||||
self.update_existing_user(user, args["defaults"])
|
||||
else:
|
||||
# If user is created, configure the user
|
||||
user = self.configure_user(user, **kwargs)
|
||||
else:
|
||||
if not user and openid_data["email"]:
|
||||
user = self.get_user_by_email(UserModel, openid_data["email"])
|
||||
if user:
|
||||
# if found by email, update the username
|
||||
setattr(user, UserModel.USERNAME_FIELD, username)
|
||||
|
||||
if not user:
|
||||
user = UserModel.objects.create(**{UserModel.USERNAME_FIELD: username}, **openid_data)
|
||||
return self.configure_user(user, **kwargs)
|
||||
|
||||
self.update_existing_user(user, openid_data)
|
||||
return user
|
||||
|
||||
def get_user_by_username(self, UserModel, username):
|
||||
"""Retrieve user by username."""
|
||||
try:
|
||||
user = UserModel.objects.get_by_natural_key(username)
|
||||
return UserModel.objects.get(**{UserModel.USERNAME_FIELD: username})
|
||||
except UserModel.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_user_by_email(self, UserModel, email):
|
||||
"""Retrieve user by email."""
|
||||
try:
|
||||
return UserModel.objects.get(email=email)
|
||||
except UserModel.DoesNotExist:
|
||||
return None
|
||||
# run this callback for a each login
|
||||
user.on_each_login()
|
||||
return user
|
||||
|
||||
def update_existing_user(self, user, kwargs):
|
||||
"""
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from django.test import TestCase
|
||||
from registrar.models import User
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
from ..backends import OpenIdConnectBackend # Adjust the import path based on your project structure
|
||||
|
||||
|
||||
|
@ -17,6 +18,7 @@ class OpenIdConnectBackendTestCase(TestCase):
|
|||
def tearDown(self) -> None:
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_authenticate_with_create_user(self):
|
||||
"""Test that authenticate creates a new user if it does not find
|
||||
existing user"""
|
||||
|
@ -32,6 +34,7 @@ class OpenIdConnectBackendTestCase(TestCase):
|
|||
self.assertEqual(user.email, "john.doe@example.com")
|
||||
self.assertEqual(user.phone, "123456789")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_authenticate_with_existing_user(self):
|
||||
"""Test that authenticate updates an existing user if it finds one.
|
||||
For this test, given_name and family_name are supplied"""
|
||||
|
@ -50,6 +53,30 @@ class OpenIdConnectBackendTestCase(TestCase):
|
|||
self.assertEqual(user.email, "john.doe@example.com")
|
||||
self.assertEqual(user.phone, "123456789")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_authenticate_with_existing_user_same_email_different_username(self):
|
||||
"""Test that authenticate updates an existing user if it finds one.
|
||||
In this case, match is to an existing record with matching email but
|
||||
a non-matching username. The existing record's username should be udpated.
|
||||
For this test, given_name and family_name are supplied"""
|
||||
# Create an existing user with the same username
|
||||
User.objects.create_user(username="old_username", email="john.doe@example.com")
|
||||
|
||||
# Ensure that the authenticate method updates the existing user
|
||||
user = self.backend.authenticate(request=None, **self.kwargs)
|
||||
self.assertIsNotNone(user)
|
||||
self.assertIsInstance(user, User)
|
||||
|
||||
# Verify that user fields are correctly updated
|
||||
self.assertEqual(user.first_name, "John")
|
||||
self.assertEqual(user.last_name, "Doe")
|
||||
self.assertEqual(user.email, "john.doe@example.com")
|
||||
self.assertEqual(user.phone, "123456789")
|
||||
self.assertEqual(user.username, "test_user")
|
||||
# Assert that a user no longer exists by the old username
|
||||
self.assertFalse(User.objects.filter(username="old_username").exists())
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_authenticate_with_existing_user_with_existing_first_last_phone(self):
|
||||
"""Test that authenticate updates an existing user if it finds one.
|
||||
For this test, given_name and family_name are not supplied.
|
||||
|
@ -79,6 +106,7 @@ class OpenIdConnectBackendTestCase(TestCase):
|
|||
self.assertEqual(user.email, "john.doe@example.com")
|
||||
self.assertEqual(user.phone, "9999999999")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_authenticate_with_existing_user_different_name_phone(self):
|
||||
"""Test that authenticate updates an existing user if it finds one.
|
||||
For this test, given_name and family_name are supplied and overwrite"""
|
||||
|
@ -100,6 +128,7 @@ class OpenIdConnectBackendTestCase(TestCase):
|
|||
self.assertEqual(user.email, "john.doe@example.com")
|
||||
self.assertEqual(user.phone, "123456789")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_authenticate_with_unknown_user(self):
|
||||
"""Test that authenticate returns None when no kwargs are supplied"""
|
||||
# Ensure that the authenticate method handles the case when the user is not found
|
||||
|
|
4721
src/package-lock.json
generated
4721
src/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -17,10 +17,13 @@
|
|||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@uswds/compile": "1.1.0",
|
||||
"@uswds/compile": "1.2.1",
|
||||
"babel-loader": "^9.2.1",
|
||||
"sass-loader": "^12.6.0",
|
||||
"webpack": "^5.96.1",
|
||||
"webpack-stream": "^7.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"semver": "^7.5.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ from django.db.models import (
|
|||
from django.db.models.functions import Concat, Coalesce
|
||||
from django.http import HttpResponseRedirect
|
||||
from registrar.models.federal_agency import FederalAgency
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.utility.admin_helpers import (
|
||||
AutocompleteSelectWithPlaceholder,
|
||||
get_action_needed_reason_default_email,
|
||||
|
@ -21,10 +22,18 @@ from registrar.utility.admin_helpers import (
|
|||
get_field_links_as_list,
|
||||
)
|
||||
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_fsm import get_available_FIELD_transitions, FSMField
|
||||
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
|
||||
from registrar.views.utility.invitation_helper import (
|
||||
get_org_membership,
|
||||
get_requested_user,
|
||||
handle_invitation_exceptions,
|
||||
)
|
||||
from waffle.decorators import flag_is_active
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
|
@ -1213,9 +1222,9 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
class SeniorOfficialAdmin(ListHeaderAdmin):
|
||||
"""Custom Senior Official Admin class."""
|
||||
|
||||
search_fields = ["first_name", "last_name", "email"]
|
||||
search_fields = ["first_name", "last_name", "email", "federal_agency__agency"]
|
||||
search_help_text = "Search by first name, last name or email."
|
||||
list_display = ["first_name", "last_name", "email", "federal_agency"]
|
||||
list_display = ["federal_agency", "first_name", "last_name", "email"]
|
||||
|
||||
# this ordering effects the ordering of results
|
||||
# in autocomplete_fields for Senior Official
|
||||
|
@ -1312,6 +1321,8 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
|
|||
search_fields = ["user__first_name", "user__last_name", "user__email", "portfolio__organization_name"]
|
||||
search_help_text = "Search by first name, last name, email, or portfolio."
|
||||
|
||||
change_form_template = "django/admin/user_portfolio_permission_change_form.html"
|
||||
|
||||
def get_roles(self, obj):
|
||||
readable_roles = obj.get_readable_roles()
|
||||
return ", ".join(readable_roles)
|
||||
|
@ -1356,6 +1367,8 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
autocomplete_fields = ["user", "domain"]
|
||||
|
||||
change_form_template = "django/admin/user_domain_role_change_form.html"
|
||||
|
||||
# Fixes a bug where non-superusers are redirected to the main page
|
||||
def delete_view(self, request, object_id, extra_context=None):
|
||||
"""Custom delete_view implementation that specifies redirect behaviour"""
|
||||
|
@ -1383,7 +1396,81 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
return super().changeform_view(request, object_id, form_url, extra_context=extra_context)
|
||||
|
||||
|
||||
class DomainInvitationAdmin(ListHeaderAdmin):
|
||||
class BaseInvitationAdmin(ListHeaderAdmin):
|
||||
"""Base class for admin classes which will customize save_model and send email invitations
|
||||
on model adds, and require custom handling of forms and form errors."""
|
||||
|
||||
def response_add(self, request, obj, post_url_continue=None):
|
||||
"""
|
||||
Override response_add to handle rendering when exceptions are raised during add model.
|
||||
|
||||
Normal flow on successful save_model on add is to redirect to changelist_view.
|
||||
If there are errors, flow is modified to instead render change form.
|
||||
"""
|
||||
# store current messages from request in storage so that they are preserved throughout the
|
||||
# method, as some flows remove and replace all messages, and so we store here to retrieve
|
||||
# later
|
||||
storage = get_messages(request)
|
||||
# Check if there are any error messages in the `messages` framework
|
||||
# error messages stop the workflow; other message levels allow flow to continue as normal
|
||||
has_errors = any(message.level_tag in ["error"] for message in storage)
|
||||
|
||||
if has_errors:
|
||||
# Re-render the change form if there are errors or warnings
|
||||
# Prepare context for rendering the change form
|
||||
|
||||
# Get the model form
|
||||
ModelForm = self.get_form(request, obj=obj)
|
||||
form = ModelForm(instance=obj)
|
||||
|
||||
# Create an AdminForm instance
|
||||
admin_form = AdminForm(
|
||||
form,
|
||||
list(self.get_fieldsets(request, obj)),
|
||||
self.get_prepopulated_fields(request, obj),
|
||||
self.get_readonly_fields(request, obj),
|
||||
model_admin=self,
|
||||
)
|
||||
media = self.media + form.media
|
||||
|
||||
opts = obj._meta
|
||||
change_form_context = {
|
||||
**self.admin_site.each_context(request), # Add admin context
|
||||
"title": f"Add {opts.verbose_name}",
|
||||
"opts": opts,
|
||||
"original": obj,
|
||||
"save_as": self.save_as,
|
||||
"has_change_permission": self.has_change_permission(request, obj),
|
||||
"add": True, # Indicate this is an "Add" form
|
||||
"change": False, # Indicate this is not a "Change" form
|
||||
"is_popup": False,
|
||||
"inline_admin_formsets": [],
|
||||
"save_on_top": self.save_on_top,
|
||||
"show_delete": self.has_delete_permission(request, obj),
|
||||
"obj": obj,
|
||||
"adminform": admin_form, # Pass the AdminForm instance
|
||||
"media": media,
|
||||
"errors": None,
|
||||
}
|
||||
return self.render_change_form(
|
||||
request,
|
||||
context=change_form_context,
|
||||
add=True,
|
||||
change=False,
|
||||
obj=obj,
|
||||
)
|
||||
|
||||
response = super().response_add(request, obj, post_url_continue)
|
||||
|
||||
# Re-add all messages from storage after `super().response_add`
|
||||
# as super().response_add resets the success messages in request
|
||||
for message in storage:
|
||||
messages.add_message(request, message.level, message.message)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class DomainInvitationAdmin(BaseInvitationAdmin):
|
||||
"""Custom domain invitation admin class."""
|
||||
|
||||
class Meta:
|
||||
|
@ -1418,7 +1505,7 @@ class DomainInvitationAdmin(ListHeaderAdmin):
|
|||
|
||||
autocomplete_fields = ["domain"]
|
||||
|
||||
change_form_template = "django/admin/email_clipboard_change_form.html"
|
||||
change_form_template = "django/admin/domain_invitation_change_form.html"
|
||||
|
||||
# Select domain invitations to change -> Domain invitations
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
|
@ -1428,8 +1515,69 @@ class DomainInvitationAdmin(ListHeaderAdmin):
|
|||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""
|
||||
Override the save_model method.
|
||||
|
||||
class PortfolioInvitationAdmin(ListHeaderAdmin):
|
||||
On creation of a new domain invitation, attempt to retrieve the invitation,
|
||||
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)
|
||||
requested_email = obj.email
|
||||
# Look up a user with that email
|
||||
requested_user = get_requested_user(requested_email)
|
||||
requestor = request.user
|
||||
|
||||
member_of_a_different_org, member_of_this_org = get_org_membership(
|
||||
domain_org, requested_email, requested_user
|
||||
)
|
||||
|
||||
try:
|
||||
if (
|
||||
flag_is_active(request, "organization_feature")
|
||||
and not flag_is_active(request, "multiple_portfolios")
|
||||
and domain_org is not None
|
||||
and not member_of_this_org
|
||||
and not member_of_a_different_org
|
||||
):
|
||||
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org)
|
||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
email=requested_email,
|
||||
portfolio=domain_org,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
# if user exists for email, immediately retrieve portfolio invitation upon creation
|
||||
if requested_user is not None:
|
||||
portfolio_invitation.retrieve()
|
||||
portfolio_invitation.save()
|
||||
messages.success(request, f"{requested_email} has been invited to the organization: {domain_org}")
|
||||
|
||||
if not send_domain_invitation_email(
|
||||
email=requested_email,
|
||||
requestor=requestor,
|
||||
domains=domain,
|
||||
is_member_of_different_org=member_of_a_different_org,
|
||||
requested_user=requested_user,
|
||||
):
|
||||
messages.warning(request, "Could not send email confirmation to existing domain managers.")
|
||||
if requested_user is not None:
|
||||
# Domain Invitation creation for an existing User
|
||||
obj.retrieve()
|
||||
# Call the parent save method to save the object
|
||||
super().save_model(request, obj, form, change)
|
||||
messages.success(request, f"{requested_email} has been invited to the domain: {domain}")
|
||||
except Exception as e:
|
||||
handle_invitation_exceptions(request, e, requested_email)
|
||||
return
|
||||
else:
|
||||
# Call the parent save method to save the object
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class PortfolioInvitationAdmin(BaseInvitationAdmin):
|
||||
"""Custom portfolio invitation admin class."""
|
||||
|
||||
form = PortfolioInvitationAdminForm
|
||||
|
@ -1452,7 +1600,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
|
|||
# Search
|
||||
search_fields = [
|
||||
"email",
|
||||
"portfolio__name",
|
||||
"portfolio__organization_name",
|
||||
]
|
||||
|
||||
# Filters
|
||||
|
@ -1468,7 +1616,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
|
|||
|
||||
autocomplete_fields = ["portfolio"]
|
||||
|
||||
change_form_template = "django/admin/email_clipboard_change_form.html"
|
||||
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):
|
||||
|
@ -1478,6 +1626,41 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
|
|||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""
|
||||
Override the save_model method.
|
||||
|
||||
Only send email on creation of the PortfolioInvitation object. Not on updates.
|
||||
Emails sent to requested user / email.
|
||||
When exceptions are raised, return without saving model.
|
||||
"""
|
||||
if not change: # Only send email if this is a new PortfolioInvitation (creation)
|
||||
portfolio = obj.portfolio
|
||||
requested_email = obj.email
|
||||
requestor = request.user
|
||||
# Look up a user with that email
|
||||
requested_user = get_requested_user(requested_email)
|
||||
|
||||
permission_exists = UserPortfolioPermission.objects.filter(
|
||||
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
|
||||
).exists()
|
||||
try:
|
||||
if not permission_exists:
|
||||
# if permission does not exist for a user with requested_email, send email
|
||||
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
|
||||
# if user exists for email, immediately retrieve portfolio invitation upon creation
|
||||
if requested_user is not None:
|
||||
obj.retrieve()
|
||||
messages.success(request, f"{requested_email} has been invited.")
|
||||
else:
|
||||
messages.warning(request, "User is already a member of this portfolio.")
|
||||
except Exception as e:
|
||||
# when exception is raised, handle and do not save the model
|
||||
handle_invitation_exceptions(request, e, requested_email)
|
||||
return
|
||||
# Call the parent save method to save the object
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class DomainInformationResource(resources.ModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -1499,22 +1682,25 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
parameter_name = "converted_generic_orgs"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
converted_generic_orgs = set()
|
||||
# Annotate the queryset to avoid Python-side iteration
|
||||
queryset = (
|
||||
DomainInformation.objects.annotate(
|
||||
converted_generic_org=Case(
|
||||
When(portfolio__organization_type__isnull=False, then="portfolio__organization_type"),
|
||||
When(portfolio__isnull=True, generic_org_type__isnull=False, then="generic_org_type"),
|
||||
default=Value(""),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.values_list("converted_generic_org", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Populate the set with tuples of (value, display value)
|
||||
for domain_info in DomainInformation.objects.all():
|
||||
converted_generic_org = domain_info.converted_generic_org_type # Actual value
|
||||
converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value
|
||||
# Filter out empty results and return sorted list of unique values
|
||||
return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org])
|
||||
|
||||
if converted_generic_org:
|
||||
converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display
|
||||
|
||||
# Sort the set by display value
|
||||
return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value
|
||||
|
||||
# Filter queryset
|
||||
def queryset(self, request, queryset):
|
||||
if self.value(): # Check if a generic org is selected in the filter
|
||||
if self.value():
|
||||
return queryset.filter(
|
||||
Q(portfolio__organization_type=self.value())
|
||||
| Q(portfolio__isnull=True, generic_org_type=self.value())
|
||||
|
@ -1830,10 +2016,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
form = DomainRequestAdminForm
|
||||
change_form_template = "django/admin/domain_request_change_form.html"
|
||||
|
||||
# ------ Filters ------
|
||||
# Define custom filters
|
||||
class StatusListFilter(MultipleChoiceListFilter):
|
||||
"""Custom status filter which is a multiple choice filter"""
|
||||
|
||||
title = "Status"
|
||||
title = "status"
|
||||
parameter_name = "status__in"
|
||||
|
||||
template = "django/admin/multiple_choice_list_filter.html"
|
||||
|
@ -1850,22 +2038,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
parameter_name = "converted_generic_orgs"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
converted_generic_orgs = set()
|
||||
# Annotate the queryset to avoid Python-side iteration
|
||||
queryset = (
|
||||
DomainRequest.objects.annotate(
|
||||
converted_generic_org=Case(
|
||||
When(portfolio__organization_type__isnull=False, then="portfolio__organization_type"),
|
||||
When(portfolio__isnull=True, generic_org_type__isnull=False, then="generic_org_type"),
|
||||
default=Value(""),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.values_list("converted_generic_org", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Populate the set with tuples of (value, display value)
|
||||
for domain_request in DomainRequest.objects.all():
|
||||
converted_generic_org = domain_request.converted_generic_org_type # Actual value
|
||||
converted_generic_org_display = domain_request.converted_generic_org_type_display # Display value
|
||||
# Filter out empty results and return sorted list of unique values
|
||||
return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org])
|
||||
|
||||
if converted_generic_org:
|
||||
converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display
|
||||
|
||||
# Sort the set by display value
|
||||
return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value
|
||||
|
||||
# Filter queryset
|
||||
def queryset(self, request, queryset):
|
||||
if self.value(): # Check if a generic org is selected in the filter
|
||||
if self.value():
|
||||
return queryset.filter(
|
||||
Q(portfolio__organization_type=self.value())
|
||||
| Q(portfolio__isnull=True, generic_org_type=self.value())
|
||||
|
@ -1877,28 +2068,43 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
If we have a portfolio, use the portfolio's federal type. If not, use the
|
||||
organization in the Domain Request object."""
|
||||
|
||||
title = "federal Type"
|
||||
title = "federal type"
|
||||
parameter_name = "converted_federal_types"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
converted_federal_types = set()
|
||||
|
||||
# Populate the set with tuples of (value, display value)
|
||||
for domain_request in DomainRequest.objects.all():
|
||||
converted_federal_type = domain_request.converted_federal_type # Actual value
|
||||
converted_federal_type_display = domain_request.converted_federal_type_display # Display value
|
||||
|
||||
if converted_federal_type:
|
||||
converted_federal_types.add(
|
||||
(converted_federal_type, converted_federal_type_display) # Value, Display
|
||||
# Annotate the queryset for efficient filtering
|
||||
queryset = (
|
||||
DomainRequest.objects.annotate(
|
||||
converted_federal_type=Case(
|
||||
When(
|
||||
portfolio__isnull=False,
|
||||
portfolio__federal_agency__federal_type__isnull=False,
|
||||
then="portfolio__federal_agency__federal_type",
|
||||
),
|
||||
When(
|
||||
portfolio__isnull=True,
|
||||
federal_agency__federal_type__isnull=False,
|
||||
then="federal_agency__federal_type",
|
||||
),
|
||||
default=Value(""),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.values_list("converted_federal_type", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Sort the set by display value
|
||||
return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value
|
||||
# Filter out empty values and return sorted unique entries
|
||||
return sorted(
|
||||
[
|
||||
(federal_type, BranchChoices.get_branch_label(federal_type))
|
||||
for federal_type in queryset
|
||||
if federal_type
|
||||
]
|
||||
)
|
||||
|
||||
# Filter queryset
|
||||
def queryset(self, request, queryset):
|
||||
if self.value(): # Check if a federal type is selected in the filter
|
||||
if self.value():
|
||||
return queryset.filter(
|
||||
Q(portfolio__federal_agency__federal_type=self.value())
|
||||
| Q(portfolio__isnull=True, federal_type=self.value())
|
||||
|
@ -1965,12 +2171,57 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
if self.value() == "0":
|
||||
return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None))
|
||||
|
||||
class PortfolioFilter(admin.SimpleListFilter):
|
||||
"""Define a custom filter for portfolio"""
|
||||
|
||||
title = _("portfolio")
|
||||
parameter_name = "portfolio__isnull"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
("1", _("Yes")),
|
||||
("0", _("No")),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() == "1":
|
||||
return queryset.filter(Q(portfolio__isnull=False))
|
||||
if self.value() == "0":
|
||||
return queryset.filter(Q(portfolio__isnull=True))
|
||||
|
||||
# ------ Custom fields ------
|
||||
def custom_election_board(self, obj):
|
||||
return "Yes" if obj.is_election_board else "No"
|
||||
|
||||
custom_election_board.admin_order_field = "is_election_board" # type: ignore
|
||||
custom_election_board.short_description = "Election office" # type: ignore
|
||||
|
||||
@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)
|
||||
|
||||
custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore
|
||||
|
||||
# ------ Converted fields ------
|
||||
# These fields map to @Property methods and
|
||||
# require these custom definitions to work properly
|
||||
@admin.display(description=_("Generic Org Type"))
|
||||
def converted_generic_org_type(self, obj):
|
||||
return obj.converted_generic_org_type_display
|
||||
|
||||
@admin.display(description=_("Organization Name"))
|
||||
def converted_organization_name(self, obj):
|
||||
# Example: Show different icons based on `status`
|
||||
if obj.portfolio:
|
||||
url = reverse("admin:registrar_portfolio_change", args=[obj.portfolio.id])
|
||||
text = obj.converted_organization_name
|
||||
return format_html('<a href="{}">{}</a>', url, text)
|
||||
else:
|
||||
return obj.converted_organization_name
|
||||
|
||||
@admin.display(description=_("Federal Agency"))
|
||||
|
@ -1989,34 +2240,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
def converted_state_territory(self, obj):
|
||||
return obj.converted_state_territory
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"requested_domain",
|
||||
"first_submitted_date",
|
||||
"last_submitted_date",
|
||||
"last_status_update",
|
||||
"status",
|
||||
"custom_election_board",
|
||||
"converted_generic_org_type",
|
||||
"converted_organization_name",
|
||||
"converted_federal_agency",
|
||||
"converted_federal_type",
|
||||
"converted_city",
|
||||
"converted_state_territory",
|
||||
"investigator",
|
||||
]
|
||||
|
||||
orderable_fk_fields = [
|
||||
("requested_domain", "name"),
|
||||
("investigator", ["first_name", "last_name"]),
|
||||
]
|
||||
|
||||
def custom_election_board(self, obj):
|
||||
return "Yes" if obj.is_election_board else "No"
|
||||
|
||||
custom_election_board.admin_order_field = "is_election_board" # type: ignore
|
||||
custom_election_board.short_description = "Election office" # type: ignore
|
||||
|
||||
# ------ Portfolio fields ------
|
||||
# Define methods to display fields from the related portfolio
|
||||
def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]:
|
||||
return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None
|
||||
|
@ -2086,10 +2310,33 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
def status_history(self, obj):
|
||||
return "No changelog to display."
|
||||
|
||||
status_history.short_description = "Status History" # type: ignore
|
||||
status_history.short_description = "Status history" # type: ignore
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"custom_requested_domain",
|
||||
"first_submitted_date",
|
||||
"last_submitted_date",
|
||||
"last_status_update",
|
||||
"status",
|
||||
"custom_election_board",
|
||||
"converted_generic_org_type",
|
||||
"converted_organization_name",
|
||||
"converted_federal_agency",
|
||||
"converted_federal_type",
|
||||
"converted_city",
|
||||
"converted_state_territory",
|
||||
"investigator",
|
||||
]
|
||||
|
||||
orderable_fk_fields = [
|
||||
("requested_domain", "name"),
|
||||
("investigator", ["first_name", "last_name"]),
|
||||
]
|
||||
|
||||
# Filters
|
||||
list_filter = (
|
||||
PortfolioFilter,
|
||||
StatusListFilter,
|
||||
GenericOrgFilter,
|
||||
FederalTypeFilter,
|
||||
|
@ -2099,13 +2346,14 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
)
|
||||
|
||||
# Search
|
||||
# NOTE: converted fields are included in the override for get_search_results
|
||||
search_fields = [
|
||||
"requested_domain__name",
|
||||
"creator__email",
|
||||
"creator__first_name",
|
||||
"creator__last_name",
|
||||
]
|
||||
search_help_text = "Search by domain or creator."
|
||||
search_help_text = "Search by domain, creator, or organization name."
|
||||
|
||||
fieldsets = [
|
||||
(
|
||||
|
@ -2271,9 +2519,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"cisa_representative_first_name",
|
||||
"cisa_representative_last_name",
|
||||
"cisa_representative_email",
|
||||
"requested_suborganization",
|
||||
"suborganization_city",
|
||||
"suborganization_state_territory",
|
||||
]
|
||||
|
||||
autocomplete_fields = [
|
||||
|
@ -2577,8 +2822,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
return response
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Display restricted warning,
|
||||
Setup the auditlog trail and pass it in extra context."""
|
||||
"""Display restricted warning, setup the auditlog trail and pass it in extra context,
|
||||
display warning that status cannot be changed from 'Approved' if domain is in Ready state"""
|
||||
|
||||
# Fetch the domain request instance
|
||||
domain_request: models.DomainRequest = models.DomainRequest.objects.get(pk=object_id)
|
||||
if domain_request.approved_domain and domain_request.approved_domain.state == models.Domain.State.READY:
|
||||
domain = domain_request.approved_domain
|
||||
# get change url for domain
|
||||
app_label = domain_request.approved_domain._meta.app_label
|
||||
model_name = domain._meta.model_name
|
||||
obj_id = domain.id
|
||||
change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id])
|
||||
|
||||
message = format_html(
|
||||
"The status of this domain request cannot be changed because it has been joined to a domain in Ready status: " # noqa: E501
|
||||
"<a href='{}'>{}</a>",
|
||||
mark_safe(change_url), # nosec
|
||||
escape(str(domain)),
|
||||
)
|
||||
messages.warning(
|
||||
request,
|
||||
message,
|
||||
)
|
||||
|
||||
obj = self.get_object(request, object_id)
|
||||
self.display_restricted_warning(request, obj)
|
||||
|
||||
|
@ -2587,7 +2854,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
try:
|
||||
# Retrieve and order audit log entries by timestamp in descending order
|
||||
audit_log_entries = LogEntry.objects.filter(object_id=object_id).order_by("-timestamp")
|
||||
audit_log_entries = LogEntry.objects.filter(
|
||||
object_id=object_id, content_type__model="domainrequest"
|
||||
).order_by("-timestamp")
|
||||
|
||||
# Process each log entry to filter based on the change criteria
|
||||
for log_entry in audit_log_entries:
|
||||
|
@ -2692,6 +2961,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
qs = qs.filter(portfolio=portfolio_id)
|
||||
return qs
|
||||
|
||||
def get_search_results(self, request, queryset, search_term):
|
||||
# Call the parent's method to apply default search logic
|
||||
base_queryset, use_distinct = super().get_search_results(request, queryset, search_term)
|
||||
|
||||
# Add custom search logic for the annotated field
|
||||
if search_term:
|
||||
annotated_queryset = queryset.filter(
|
||||
# converted_organization_name
|
||||
Q(portfolio__organization_name__icontains=search_term)
|
||||
| Q(portfolio__isnull=True, organization_name__icontains=search_term)
|
||||
)
|
||||
|
||||
# Combine the two querysets using union
|
||||
combined_queryset = base_queryset | annotated_queryset
|
||||
else:
|
||||
combined_queryset = base_queryset
|
||||
|
||||
return combined_queryset, use_distinct
|
||||
|
||||
|
||||
class TransitionDomainAdmin(ListHeaderAdmin):
|
||||
"""Custom transition domain admin class."""
|
||||
|
@ -2963,59 +3251,86 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
parameter_name = "converted_generic_orgs"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
converted_generic_orgs = set()
|
||||
# Annotate the queryset to avoid Python-side iteration
|
||||
queryset = (
|
||||
Domain.objects.annotate(
|
||||
converted_generic_org=Case(
|
||||
When(
|
||||
domain_info__isnull=False,
|
||||
domain_info__portfolio__organization_type__isnull=False,
|
||||
then="domain_info__portfolio__organization_type",
|
||||
),
|
||||
When(
|
||||
domain_info__isnull=False,
|
||||
domain_info__portfolio__isnull=True,
|
||||
domain_info__generic_org_type__isnull=False,
|
||||
then="domain_info__generic_org_type",
|
||||
),
|
||||
default=Value(""),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.values_list("converted_generic_org", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Populate the set with tuples of (value, display value)
|
||||
for domain_info in DomainInformation.objects.all():
|
||||
converted_generic_org = domain_info.converted_generic_org_type # Actual value
|
||||
converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value
|
||||
# Filter out empty results and return sorted list of unique values
|
||||
return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org])
|
||||
|
||||
if converted_generic_org:
|
||||
converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display
|
||||
|
||||
# Sort the set by display value
|
||||
return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value
|
||||
|
||||
# Filter queryset
|
||||
def queryset(self, request, queryset):
|
||||
if self.value(): # Check if a generic org is selected in the filter
|
||||
if self.value():
|
||||
return queryset.filter(
|
||||
Q(domain_info__portfolio__organization_type=self.value())
|
||||
| Q(domain_info__portfolio__isnull=True, domain_info__generic_org_type=self.value())
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
class FederalTypeFilter(admin.SimpleListFilter):
|
||||
"""Custom Federal Type filter that accomodates portfolio feature.
|
||||
If we have a portfolio, use the portfolio's federal type. If not, use the
|
||||
federal type in the Domain Information object."""
|
||||
organization in the Domain Request object."""
|
||||
|
||||
title = "federal type"
|
||||
parameter_name = "converted_federal_types"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
converted_federal_types = set()
|
||||
|
||||
# Populate the set with tuples of (value, display value)
|
||||
for domain_info in DomainInformation.objects.all():
|
||||
converted_federal_type = domain_info.converted_federal_type # Actual value
|
||||
converted_federal_type_display = domain_info.converted_federal_type_display # Display value
|
||||
|
||||
if converted_federal_type:
|
||||
converted_federal_types.add(
|
||||
(converted_federal_type, converted_federal_type_display) # Value, Display
|
||||
# Annotate the queryset for efficient filtering
|
||||
queryset = (
|
||||
Domain.objects.annotate(
|
||||
converted_federal_type=Case(
|
||||
When(
|
||||
domain_info__isnull=False,
|
||||
domain_info__portfolio__isnull=False,
|
||||
then=F("domain_info__portfolio__federal_agency__federal_type"),
|
||||
),
|
||||
When(
|
||||
domain_info__isnull=False,
|
||||
domain_info__portfolio__isnull=True,
|
||||
domain_info__federal_type__isnull=False,
|
||||
then="domain_info__federal_agency__federal_type",
|
||||
),
|
||||
default=Value(""),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.values_list("converted_federal_type", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Sort the set by display value
|
||||
return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value
|
||||
# Filter out empty values and return sorted unique entries
|
||||
return sorted(
|
||||
[
|
||||
(federal_type, BranchChoices.get_branch_label(federal_type))
|
||||
for federal_type in queryset
|
||||
if federal_type
|
||||
]
|
||||
)
|
||||
|
||||
# Filter queryset
|
||||
def queryset(self, request, queryset):
|
||||
if self.value(): # Check if a federal type is selected in the filter
|
||||
if self.value():
|
||||
return queryset.filter(
|
||||
Q(domain_info__portfolio__federal_agency__federal_type=self.value())
|
||||
| Q(domain_info__portfolio__isnull=True, domain_info__federal_agency__federal_type=self.value())
|
||||
Q(domain_info__portfolio__federal_type=self.value())
|
||||
| Q(domain_info__portfolio__isnull=True, domain_info__federal_type=self.value())
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
@ -3746,9 +4061,9 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
"senior_official",
|
||||
]
|
||||
|
||||
analyst_readonly_fields = [
|
||||
"organization_name",
|
||||
]
|
||||
# Even though this is empty, I will leave it as a stub for easy changes in the future
|
||||
# rather than strip it out of our logic.
|
||||
analyst_readonly_fields = [] # type: ignore
|
||||
|
||||
def get_admin_users(self, obj):
|
||||
# Filter UserPortfolioPermission objects related to the portfolio
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
* - tooltip dynamic content updated to include nested element (for better sizing control)
|
||||
* - modal exposed to window to be accessible in other js files
|
||||
* - fixed bug in createHeaderButton which added newlines to header button tooltips
|
||||
* - modified combobox to handle error class
|
||||
*/
|
||||
|
||||
if ("document" in window.self) {
|
||||
|
@ -1213,6 +1214,11 @@ const enhanceComboBox = _comboBoxEl => {
|
|||
input.setAttribute("class", INPUT_CLASS);
|
||||
input.setAttribute("type", "text");
|
||||
input.setAttribute("role", "combobox");
|
||||
// DOTGOV - handle error class for combobox
|
||||
// Check if 'usa-input--error' exists in selectEl and add it to input if true
|
||||
if (selectEl.classList.contains('usa-input--error')) {
|
||||
input.classList.add('usa-input--error');
|
||||
}
|
||||
additionalAttributes.forEach(attr => Object.keys(attr).forEach(key => {
|
||||
const value = Sanitizer.escapeHTML`${attr[key]}`;
|
||||
input.setAttribute(key, value);
|
||||
|
|
|
@ -629,6 +629,51 @@ export function initRejectedEmail() {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A function that handles the suborganzation and requested suborganization fields and buttons.
|
||||
* - Fieldwise: Hooks to the sub_organization, suborganization_city, and suborganization_state_territory fields.
|
||||
* On change, this function checks if any of these fields are not empty:
|
||||
* sub_organization, suborganization_city, and suborganization_state_territory.
|
||||
* If they aren't, then we show the "clear" button. If they are, then we hide it because we don't need it.
|
||||
*
|
||||
* - Buttonwise: Hooks to the #clear-requested-suborganization button.
|
||||
* On click, this will clear the input value of sub_organization, suborganization_city, and suborganization_state_territory.
|
||||
*/
|
||||
function handleSuborgFieldsAndButtons() {
|
||||
const requestedSuborganizationField = document.getElementById("id_requested_suborganization");
|
||||
const suborganizationCity = document.getElementById("id_suborganization_city");
|
||||
const suborganizationStateTerritory = document.getElementById("id_suborganization_state_territory");
|
||||
const rejectButton = document.querySelector("#clear-requested-suborganization");
|
||||
|
||||
// Ensure that every variable is present before proceeding
|
||||
if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) {
|
||||
console.warn("handleSuborganizationSelection() => Could not find required fields.")
|
||||
return;
|
||||
}
|
||||
|
||||
function handleRejectButtonVisibility() {
|
||||
if (requestedSuborganizationField.value || suborganizationCity.value || suborganizationStateTerritory.value) {
|
||||
showElement(rejectButton);
|
||||
}else {
|
||||
hideElement(rejectButton)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRejectButton() {
|
||||
// Clear the text fields
|
||||
requestedSuborganizationField.value = "";
|
||||
suborganizationCity.value = "";
|
||||
suborganizationStateTerritory.value = "";
|
||||
// Update button visibility after clearing
|
||||
handleRejectButtonVisibility();
|
||||
}
|
||||
rejectButton.addEventListener("click", handleRejectButton)
|
||||
requestedSuborganizationField.addEventListener("blur", handleRejectButtonVisibility);
|
||||
suborganizationCity.addEventListener("blur", handleRejectButtonVisibility);
|
||||
suborganizationStateTerritory.addEventListener("change", handleRejectButtonVisibility);
|
||||
}
|
||||
|
||||
/**
|
||||
* A function for dynamic DomainRequest fields
|
||||
*/
|
||||
|
@ -636,5 +681,6 @@ export function initDynamicDomainRequestFields(){
|
|||
const domainRequestPage = document.getElementById("domainrequest_form");
|
||||
if (domainRequestPage) {
|
||||
handlePortfolioSelection();
|
||||
handleSuborgFieldsAndButtons();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,6 +49,13 @@ export function handlePortfolioSelection(
|
|||
const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization");
|
||||
const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly");
|
||||
const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null;
|
||||
// These requested suborganization fields only exist on the domain request page
|
||||
const rejectSuborganizationButton = document.querySelector("#clear-requested-suborganization");
|
||||
const requestedSuborganizationFieldInput = document.getElementById("id_requested_suborganization");
|
||||
const suborganizationCityInput = document.getElementById("id_suborganization_city");
|
||||
const suborganizationStateTerritoryInput = document.getElementById("id_suborganization_state_territory");
|
||||
|
||||
// Global var to track page load
|
||||
let isPageLoading = true;
|
||||
|
||||
/**
|
||||
|
@ -469,11 +476,28 @@ export function handlePortfolioSelection(
|
|||
if (requestedSuborganizationField) showElement(requestedSuborganizationField);
|
||||
if (suborganizationCity) showElement(suborganizationCity);
|
||||
if (suborganizationStateTerritory) showElement(suborganizationStateTerritory);
|
||||
|
||||
// == LOGIC FOR THE DOMAIN REQUEST PAGE == //
|
||||
// Handle rejectSuborganizationButton (display of the clear requested suborg button).
|
||||
// Basically, this button should only be visible when we have data for suborg, city, and state_territory.
|
||||
// The function handleSuborgFieldsAndButtons() in domain-request-form.js handles doing this same logic
|
||||
// but on field input for city, state_territory, and the suborg field.
|
||||
// If it doesn't exist, don't do anything.
|
||||
if (rejectSuborganizationButton){
|
||||
if (requestedSuborganizationFieldInput?.value || suborganizationCityInput?.value || suborganizationStateTerritoryInput?.value) {
|
||||
showElement(rejectSuborganizationButton);
|
||||
}else {
|
||||
hideElement(rejectSuborganizationButton);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Hide suborganization request fields if suborganization is selected
|
||||
if (requestedSuborganizationField) hideElement(requestedSuborganizationField);
|
||||
if (suborganizationCity) hideElement(suborganizationCity);
|
||||
if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory);
|
||||
|
||||
// == LOGIC FOR THE DOMAIN REQUEST PAGE == //
|
||||
if (rejectSuborganizationButton) hideElement(rejectSuborganizationButton);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,113 +0,0 @@
|
|||
import { hideElement, showElement } from './helpers.js';
|
||||
|
||||
export function loadInitialValuesForComboBoxes() {
|
||||
var overrideDefaultClearButton = true;
|
||||
var isTyping = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', (event) => {
|
||||
handleAllComboBoxElements();
|
||||
});
|
||||
|
||||
function handleAllComboBoxElements() {
|
||||
const comboBoxElements = document.querySelectorAll(".usa-combo-box");
|
||||
comboBoxElements.forEach(comboBox => {
|
||||
const input = comboBox.querySelector("input");
|
||||
const select = comboBox.querySelector("select");
|
||||
if (!input || !select) {
|
||||
console.warn("No combobox element found");
|
||||
return;
|
||||
}
|
||||
// Set the initial value of the combobox
|
||||
let initialValue = select.getAttribute("data-default-value");
|
||||
let clearInputButton = comboBox.querySelector(".usa-combo-box__clear-input");
|
||||
if (!clearInputButton) {
|
||||
console.warn("No clear element found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Override the default clear button behavior such that it no longer clears the input,
|
||||
// it just resets to the data-initial-value.
|
||||
// Due to the nature of how uswds works, this is slightly hacky.
|
||||
// Use a MutationObserver to watch for changes in the dropdown list
|
||||
const dropdownList = comboBox.querySelector(`#${input.id}--list`);
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === "childList") {
|
||||
addBlankOption(clearInputButton, dropdownList, initialValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Configure the observer to watch for changes in the dropdown list
|
||||
const config = { childList: true, subtree: true };
|
||||
observer.observe(dropdownList, config);
|
||||
|
||||
// Input event listener to detect typing
|
||||
input.addEventListener("input", () => {
|
||||
isTyping = true;
|
||||
});
|
||||
|
||||
// Blur event listener to reset typing state
|
||||
input.addEventListener("blur", () => {
|
||||
isTyping = false;
|
||||
});
|
||||
|
||||
// Hide the reset button when there is nothing to reset.
|
||||
// Do this once on init, then everytime a change occurs.
|
||||
updateClearButtonVisibility(select, initialValue, clearInputButton)
|
||||
select.addEventListener("change", () => {
|
||||
updateClearButtonVisibility(select, initialValue, clearInputButton)
|
||||
});
|
||||
|
||||
// Change the default input behaviour - have it reset to the data default instead
|
||||
clearInputButton.addEventListener("click", (e) => {
|
||||
if (overrideDefaultClearButton && initialValue) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
input.click();
|
||||
// Find the dropdown option with the desired value
|
||||
const dropdownOptions = document.querySelectorAll(".usa-combo-box__list-option");
|
||||
if (dropdownOptions) {
|
||||
dropdownOptions.forEach(option => {
|
||||
if (option.getAttribute("data-value") === initialValue) {
|
||||
// Simulate a click event on the dropdown option
|
||||
option.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateClearButtonVisibility(select, initialValue, clearInputButton) {
|
||||
if (select.value === initialValue) {
|
||||
hideElement(clearInputButton);
|
||||
}else {
|
||||
showElement(clearInputButton)
|
||||
}
|
||||
}
|
||||
|
||||
function addBlankOption(clearInputButton, dropdownList, initialValue) {
|
||||
if (dropdownList && !dropdownList.querySelector('[data-value=""]') && !isTyping) {
|
||||
const blankOption = document.createElement("li");
|
||||
blankOption.setAttribute("role", "option");
|
||||
blankOption.setAttribute("data-value", "");
|
||||
blankOption.classList.add("usa-combo-box__list-option");
|
||||
if (!initialValue){
|
||||
blankOption.classList.add("usa-combo-box__list-option--selected")
|
||||
}
|
||||
blankOption.textContent = "⎯";
|
||||
|
||||
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
|
||||
blankOption.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
overrideDefaultClearButton = false;
|
||||
// Trigger the default clear behavior
|
||||
clearInputButton.click();
|
||||
overrideDefaultClearButton = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
15
src/registrar/assets/src/js/getgov/domain-dnssec.js
Normal file
15
src/registrar/assets/src/js/getgov/domain-dnssec.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { submitForm } from './helpers.js';
|
||||
|
||||
export function initDomainDNSSEC() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let domain_dnssec_page = document.getElementById("domain-dnssec");
|
||||
if (domain_dnssec_page) {
|
||||
const button = document.getElementById("disable-dnssec-button");
|
||||
if (button) {
|
||||
button.addEventListener("click", function () {
|
||||
submitForm("disable-dnssec-form");
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
27
src/registrar/assets/src/js/getgov/domain-dsdata.js
Normal file
27
src/registrar/assets/src/js/getgov/domain-dsdata.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { submitForm } from './helpers.js';
|
||||
|
||||
export function initDomainDSData() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let domain_dsdata_page = document.getElementById("domain-dsdata");
|
||||
if (domain_dsdata_page) {
|
||||
const override_button = document.getElementById("disable-override-click-button");
|
||||
const cancel_button = document.getElementById("btn-cancel-click-button");
|
||||
const cancel_close_button = document.getElementById("btn-cancel-click-close-button");
|
||||
if (override_button) {
|
||||
override_button.addEventListener("click", function () {
|
||||
submitForm("disable-override-click-form");
|
||||
});
|
||||
}
|
||||
if (cancel_button) {
|
||||
cancel_button.addEventListener("click", function () {
|
||||
submitForm("btn-cancel-click-form");
|
||||
});
|
||||
}
|
||||
if (cancel_close_button) {
|
||||
cancel_close_button.addEventListener("click", function () {
|
||||
submitForm("btn-cancel-click-form");
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
20
src/registrar/assets/src/js/getgov/domain-managers.js
Normal file
20
src/registrar/assets/src/js/getgov/domain-managers.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { submitForm } from './helpers.js';
|
||||
|
||||
export function initDomainManagersPage() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let domain_managers_page = document.getElementById("domain-managers");
|
||||
if (domain_managers_page) {
|
||||
// Add event listeners for all buttons matching user-delete-button-{NUMBER}
|
||||
const deleteButtons = document.querySelectorAll('[id^="user-delete-button-"]'); // Select buttons with ID starting with "user-delete-button-"
|
||||
deleteButtons.forEach((button) => {
|
||||
const buttonId = button.id; // e.g., "user-delete-button-1"
|
||||
const number = buttonId.split('-').pop(); // Extract the NUMBER part
|
||||
const formId = `user-delete-form-${number}`; // Generate the corresponding form ID
|
||||
|
||||
button.addEventListener("click", function () {
|
||||
submitForm(formId); // Pass the form ID to submitForm
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
12
src/registrar/assets/src/js/getgov/domain-request-form.js
Normal file
12
src/registrar/assets/src/js/getgov/domain-request-form.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { submitForm } from './helpers.js';
|
||||
|
||||
export function initDomainRequestForm() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const button = document.getElementById("domain-request-form-submit-button");
|
||||
if (button) {
|
||||
button.addEventListener("click", function () {
|
||||
submitForm("submit-domain-request-form");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
19
src/registrar/assets/src/js/getgov/form-errors.js
Normal file
19
src/registrar/assets/src/js/getgov/form-errors.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
export function initFormErrorHandling() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const errorSummary = document.getElementById('form-errors');
|
||||
const firstErrorField = document.querySelector('.usa-input--error');
|
||||
if (firstErrorField) {
|
||||
// Scroll to the first field in error
|
||||
firstErrorField.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// Add focus to the first field in error
|
||||
setTimeout(() => {
|
||||
firstErrorField.focus();
|
||||
}, 50);
|
||||
} else if (errorSummary) {
|
||||
// Scroll to the error summary
|
||||
errorSummary.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
});
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
* accessible directly in getgov.min.js
|
||||
*
|
||||
*/
|
||||
export function initializeTooltips() {
|
||||
export function uswdsInitializeTooltips() {
|
||||
function checkTooltip() {
|
||||
// Check that the tooltip library is loaded, and if not, wait and retry
|
||||
if (window.tooltip && typeof window.tooltip.init === 'function') {
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
export function hideElement(element) {
|
||||
if (element) {
|
||||
element.classList.add('display-none');
|
||||
} else {
|
||||
throw new Error('hideElement expected a passed DOM element as an argument, but none was provided.');
|
||||
}
|
||||
};
|
||||
|
||||
export function showElement(element) {
|
||||
if (element) {
|
||||
element.classList.remove('display-none');
|
||||
} else {
|
||||
throw new Error('showElement expected a passed DOM element as an argument, but none was provided.');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -75,3 +83,16 @@ export function debounce(handler, cooldown=600) {
|
|||
export function getCsrfToken() {
|
||||
return document.querySelector('input[name="csrfmiddlewaretoken"]').value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to submit a form
|
||||
* @param {} form_id - the id of the form to be submitted
|
||||
*/
|
||||
export function submitForm(form_id) {
|
||||
let form = document.getElementById(form_id);
|
||||
if (form) {
|
||||
form.submit();
|
||||
} else {
|
||||
console.error("Form '" + form_id + "' not found.");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,15 +3,18 @@ import { initDomainValidators } from './domain-validators.js';
|
|||
import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js';
|
||||
import { initializeUrbanizationToggle } from './urbanization.js';
|
||||
import { userProfileListener, finishUserSetupListener } from './user-profile.js';
|
||||
import { loadInitialValuesForComboBoxes } from './combobox.js';
|
||||
import { handleRequestingEntityFieldset } from './requesting-entity.js';
|
||||
import { initDomainsTable } from './table-domains.js';
|
||||
import { initDomainRequestsTable } from './table-domain-requests.js';
|
||||
import { initMembersTable } from './table-members.js';
|
||||
import { initMemberDomainsTable } from './table-member-domains.js';
|
||||
import { initEditMemberDomainsTable } from './table-edit-member-domains.js';
|
||||
import { initPortfolioMemberPageToggle } from './portfolio-member-page.js';
|
||||
import { initAddNewMemberPageListeners } from './portfolio-member-page.js';
|
||||
import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js';
|
||||
import { initDomainRequestForm } from './domain-request-form.js';
|
||||
import { initDomainManagersPage } from './domain-managers.js';
|
||||
import { initDomainDSData } from './domain-dsdata.js';
|
||||
import { initDomainDNSSEC } from './domain-dnssec.js';
|
||||
import { initFormErrorHandling } from './form-errors.js';
|
||||
|
||||
initDomainValidators();
|
||||
|
||||
|
@ -21,21 +24,12 @@ nameserversFormListener();
|
|||
|
||||
hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees');
|
||||
hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null);
|
||||
hookupRadioTogglerListener(
|
||||
'member_access_level',
|
||||
{
|
||||
'admin': 'new-member-admin-permissions',
|
||||
'basic': 'new-member-basic-permissions'
|
||||
}
|
||||
);
|
||||
hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null);
|
||||
initializeUrbanizationToggle();
|
||||
|
||||
userProfileListener();
|
||||
finishUserSetupListener();
|
||||
|
||||
loadInitialValuesForComboBoxes();
|
||||
|
||||
handleRequestingEntityFieldset();
|
||||
|
||||
initDomainsTable();
|
||||
|
@ -44,5 +38,14 @@ initMembersTable();
|
|||
initMemberDomainsTable();
|
||||
initEditMemberDomainsTable();
|
||||
|
||||
initPortfolioMemberPageToggle();
|
||||
initDomainRequestForm();
|
||||
initDomainManagersPage();
|
||||
initDomainDSData();
|
||||
initDomainDNSSEC();
|
||||
|
||||
initFormErrorHandling();
|
||||
|
||||
// Init the portfolio new member page
|
||||
initPortfolioMemberPageRadio();
|
||||
initPortfolioNewMemberPageToggle();
|
||||
initAddNewMemberPageListeners();
|
||||
|
|
|
@ -2,9 +2,10 @@ import { uswdsInitializeModals } from './helpers-uswds.js';
|
|||
import { getCsrfToken } from './helpers.js';
|
||||
import { generateKebabHTML } from './table-base.js';
|
||||
import { MembersTable } from './table-members.js';
|
||||
import { hookupRadioTogglerListener } from './radios.js';
|
||||
|
||||
// This is specifically for the Member Profile (Manage Member) Page member/invitation removal
|
||||
export function initPortfolioMemberPageToggle() {
|
||||
export function initPortfolioNewMemberPageToggle() {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const wrapperDeleteAction = document.getElementById("wrapper-delete-action")
|
||||
if (wrapperDeleteAction) {
|
||||
|
@ -17,11 +18,11 @@ export function initPortfolioMemberPageToggle() {
|
|||
const unique_id = `${member_type}-${member_id}`;
|
||||
|
||||
let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member";
|
||||
wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`);
|
||||
wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`);
|
||||
|
||||
// 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
|
||||
MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction);
|
||||
MembersTable.addMemberDeleteModal(num_domains, member_email || member_name || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction);
|
||||
|
||||
uswdsInitializeModals();
|
||||
|
||||
|
@ -86,14 +87,6 @@ export function initAddNewMemberPageListeners() {
|
|||
});
|
||||
});
|
||||
|
||||
/*
|
||||
Helper function to capitalize the first letter in a string (for display purposes)
|
||||
*/
|
||||
function capitalizeFirstLetter(text) {
|
||||
if (!text) return ''; // Return empty string if input is falsy
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
/*
|
||||
Populates contents of the "Add Member" confirmation modal
|
||||
*/
|
||||
|
@ -101,6 +94,8 @@ export function initAddNewMemberPageListeners() {
|
|||
const permissionDetailsContainer = document.getElementById("permission_details");
|
||||
permissionDetailsContainer.innerHTML = ""; // Clear previous content
|
||||
|
||||
if (permission_details_div_id == 'member-basic-permissions') {
|
||||
// for basic users, display values are based on selections in the form
|
||||
// Get all permission sections (divs with h3 and radio inputs)
|
||||
const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`);
|
||||
|
||||
|
@ -119,24 +114,39 @@ export function initAddNewMemberPageListeners() {
|
|||
let selectedPermission = "No permission selected";
|
||||
if (selectedRadio) {
|
||||
const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`);
|
||||
selectedPermission = label ? label.textContent : "No permission selected";
|
||||
if (label) {
|
||||
// Get only the text node content (excluding subtext in <p>)
|
||||
const mainText = Array.from(label.childNodes)
|
||||
.filter(node => node.nodeType === Node.TEXT_NODE)
|
||||
.map(node => node.textContent.trim())
|
||||
.join(""); // Combine and trim whitespace
|
||||
selectedPermission = mainText || "No permission selected";
|
||||
}
|
||||
|
||||
// Create new elements for the modal content
|
||||
const titleElement = document.createElement("h4");
|
||||
titleElement.textContent = sectionTitle;
|
||||
titleElement.classList.add("text-primary");
|
||||
titleElement.classList.add("margin-bottom-0");
|
||||
|
||||
const permissionElement = document.createElement("p");
|
||||
permissionElement.textContent = selectedPermission;
|
||||
permissionElement.classList.add("margin-top-0");
|
||||
|
||||
// Append to the modal content container
|
||||
permissionDetailsContainer.appendChild(titleElement);
|
||||
permissionDetailsContainer.appendChild(permissionElement);
|
||||
}
|
||||
appendPermissionInContainer(sectionTitle, selectedPermission, permissionDetailsContainer);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// for admin users, the permissions are always the same
|
||||
appendPermissionInContainer('Domains', 'Viewer, all', 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 permissionElement = document.createElement("p");
|
||||
permissionElement.textContent = permissionDisplay;
|
||||
permissionElement.classList.add("margin-top-0");
|
||||
|
||||
// Append to the content container
|
||||
permissionContainer.appendChild(titleElement);
|
||||
permissionContainer.appendChild(permissionElement);
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -148,18 +158,25 @@ export function initAddNewMemberPageListeners() {
|
|||
let emailValue = document.getElementById('id_email').value;
|
||||
document.getElementById('modalEmail').textContent = emailValue;
|
||||
|
||||
// Get selected radio button for access level
|
||||
let selectedAccess = document.querySelector('input[name="member_access_level"]:checked');
|
||||
// Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button)
|
||||
// This value does not have the first letter capitalized so let's capitalize it
|
||||
let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected";
|
||||
// Get selected radio button for member access level
|
||||
let selectedAccess = document.querySelector('input[name="role"]:checked');
|
||||
// Map the access level values to user-friendly labels
|
||||
const accessLevelMapping = {
|
||||
organization_admin: "Admin",
|
||||
organization_member: "Basic",
|
||||
};
|
||||
// Determine the access text based on the selected value
|
||||
let accessText = selectedAccess
|
||||
? accessLevelMapping[selectedAccess.value] || "Unknown access level"
|
||||
: "No access level selected";
|
||||
// Update the modal with the appropriate member access level text
|
||||
document.getElementById('modalAccessLevel').textContent = accessText;
|
||||
|
||||
// Populate permission details based on access level
|
||||
if (selectedAccess && selectedAccess.value === 'admin') {
|
||||
populatePermissionDetails('new-member-admin-permissions');
|
||||
if (selectedAccess && selectedAccess.value === 'organization_admin') {
|
||||
populatePermissionDetails('admin');
|
||||
} else {
|
||||
populatePermissionDetails('new-member-basic-permissions');
|
||||
populatePermissionDetails('member-basic-permissions');
|
||||
}
|
||||
|
||||
//------- Show the modal
|
||||
|
@ -170,3 +187,20 @@ export function initAddNewMemberPageListeners() {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
// Initalize the radio for the member pages
|
||||
export function initPortfolioMemberPageRadio() {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
let memberForm = document.getElementById("member_form");
|
||||
let newMemberForm = document.getElementById("add_member_form")
|
||||
if (memberForm || newMemberForm) {
|
||||
hookupRadioTogglerListener(
|
||||
'role',
|
||||
{
|
||||
'organization_admin': '',
|
||||
'organization_member': 'member-basic-permissions'
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -38,14 +38,14 @@ export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, eleme
|
|||
**/
|
||||
export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) {
|
||||
// Get the radio buttons
|
||||
let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]');
|
||||
let radioButtons = document.querySelectorAll(`input[name="${radioButtonName}"]`);
|
||||
|
||||
// Extract the list of all element IDs from the valueToElementMap
|
||||
let allElementIds = Object.values(valueToElementMap);
|
||||
|
||||
function handleRadioButtonChange() {
|
||||
// Find the checked radio button
|
||||
let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked');
|
||||
let radioButtonChecked = document.querySelector(`input[name="${radioButtonName}"]:checked`);
|
||||
let selectedValue = radioButtonChecked ? radioButtonChecked.value : null;
|
||||
|
||||
// Hide all elements by default
|
||||
|
@ -65,7 +65,7 @@ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) {
|
|||
}
|
||||
}
|
||||
|
||||
if (radioButtons.length) {
|
||||
if (radioButtons && radioButtons.length) {
|
||||
// Add event listener to each radio button
|
||||
radioButtons.forEach(function (radioButton) {
|
||||
radioButton.addEventListener('change', handleRadioButtonChange);
|
||||
|
|
|
@ -9,14 +9,15 @@ export function handleRequestingEntityFieldset() {
|
|||
const formPrefix = "portfolio_requesting_entity";
|
||||
const radioFieldset = document.getElementById(`id_${formPrefix}-requesting_entity_is_suborganization__fieldset`);
|
||||
const radios = radioFieldset?.querySelectorAll(`input[name="${formPrefix}-requesting_entity_is_suborganization"]`);
|
||||
const select = document.getElementById(`id_${formPrefix}-sub_organization`);
|
||||
const selectParent = select?.parentElement;
|
||||
const input = document.getElementById(`id_${formPrefix}-sub_organization`);
|
||||
const inputGrandParent = input?.parentElement?.parentElement;
|
||||
const select = input?.previousElementSibling;
|
||||
const suborgContainer = document.getElementById("suborganization-container");
|
||||
const suborgDetailsContainer = document.getElementById("suborganization-container__details");
|
||||
const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value;
|
||||
const suborgAddtlInstruction = document.getElementById("suborganization-addtl-instruction");
|
||||
// Make sure all crucial page elements exist before proceeding.
|
||||
// This more or less ensures that we are on the Requesting Entity page, and not elsewhere.
|
||||
if (!radios || !select || !selectParent || !suborgContainer || !suborgDetailsContainer) return;
|
||||
if (!radios || !input || !select || !inputGrandParent || !suborgContainer || !suborgDetailsContainer) return;
|
||||
|
||||
// requestingSuborganization: This just broadly determines if they're requesting a suborg at all
|
||||
// requestingNewSuborganization: This variable determines if the user is trying to *create* a new suborganization or not.
|
||||
|
@ -26,13 +27,14 @@ export function handleRequestingEntityFieldset() {
|
|||
function toggleSuborganization(radio=null) {
|
||||
if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True";
|
||||
requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer);
|
||||
if (select.options.length == 1) { // other is the only option
|
||||
hideElement(inputGrandParent); // Hide the combo box and indicate requesting new suborg
|
||||
hideElement(suborgAddtlInstruction); // Hide additional instruction related to the list
|
||||
requestingNewSuborganization.value = "True";
|
||||
} else {
|
||||
requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False";
|
||||
requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer);
|
||||
}
|
||||
|
||||
// Add fake "other" option to sub_organization select
|
||||
if (select && !Array.from(select.options).some(option => option.value === "other")) {
|
||||
select.add(new Option(subOrgCreateNewOption, "other"));
|
||||
requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer);
|
||||
}
|
||||
|
||||
if (requestingNewSuborganization.value === "True") {
|
||||
|
|
|
@ -93,7 +93,6 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
|
|||
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
||||
</svg>` : ''}
|
||||
${modal_button_text}
|
||||
<span class="usa-sr-only">${screen_reader_text}</span>
|
||||
</a>
|
||||
`;
|
||||
|
||||
|
@ -107,6 +106,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
|
|||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
|
||||
aria-expanded="false"
|
||||
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">
|
||||
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
|
||||
|
@ -129,7 +129,7 @@ export class BaseTable {
|
|||
this.displayName = itemName;
|
||||
this.sectionSelector = itemName + 's';
|
||||
this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`);
|
||||
this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`);
|
||||
this.tableHeaderSortButtons = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable] button`);
|
||||
this.currentSortBy = 'id';
|
||||
this.currentOrder = 'asc';
|
||||
this.currentStatus = [];
|
||||
|
@ -143,7 +143,7 @@ export class BaseTable {
|
|||
this.statusCheckboxes = document.querySelectorAll(`.${this.sectionSelector} input[name="filter-status"]`);
|
||||
this.statusIndicator = document.getElementById(`${this.sectionSelector}__filter-indicator`);
|
||||
this.statusToggle = document.getElementById(`${this.sectionSelector}__usa-button--filter`);
|
||||
this.noTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`);
|
||||
this.noDataTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`);
|
||||
this.noSearchResultsWrapper = document.getElementById(`${this.sectionSelector}__no-search-results`);
|
||||
this.portfolioElement = document.getElementById('portfolio-js-value');
|
||||
this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null;
|
||||
|
@ -284,15 +284,18 @@ export class BaseTable {
|
|||
showElement(dataWrapper);
|
||||
hideElement(noSearchResultsWrapper);
|
||||
hideElement(noDataWrapper);
|
||||
this.tableAnnouncementRegion.innerHTML = '';
|
||||
} else {
|
||||
hideElement(dataWrapper);
|
||||
showElement(noSearchResultsWrapper);
|
||||
hideElement(noDataWrapper);
|
||||
this.tableAnnouncementRegion.innerHTML = this.noSearchResultsWrapper.innerHTML;
|
||||
}
|
||||
} else {
|
||||
hideElement(dataWrapper);
|
||||
hideElement(noSearchResultsWrapper);
|
||||
showElement(noDataWrapper);
|
||||
this.tableAnnouncementRegion.innerHTML = this.noDataWrapper.innerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -300,13 +303,18 @@ export class BaseTable {
|
|||
* A helper that resets sortable table headers
|
||||
*
|
||||
*/
|
||||
unsetHeader = (header) => {
|
||||
unsetHeader = (headerSortButton) => {
|
||||
let header = headerSortButton.closest('th');
|
||||
if (header) {
|
||||
header.removeAttribute('aria-sort');
|
||||
let headerName = header.innerText;
|
||||
const headerLabel = `${headerName}, sortable column, currently unsorted"`;
|
||||
const headerButtonLabel = `Click to sort by ascending order.`;
|
||||
header.setAttribute("aria-label", headerLabel);
|
||||
header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel);
|
||||
} else {
|
||||
console.warn('Issue with DOM');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -375,6 +383,13 @@ export class BaseTable {
|
|||
*/
|
||||
loadModals(page, total, unfiltered_total) {}
|
||||
|
||||
/**
|
||||
* Loads tooltips + sets up event listeners
|
||||
* "Activates" the tooltips after the DOM updates
|
||||
* Utilizes "uswdsInitializeTooltips"
|
||||
*/
|
||||
initializeTooltips() {}
|
||||
|
||||
/**
|
||||
* Allows us to customize the table display based on specific conditions and a user's permissions
|
||||
* Dynamically manages the visibility set up of columns, adding/removing headers
|
||||
|
@ -382,7 +397,7 @@ export class BaseTable {
|
|||
* for a member, they will also see the kebab column)
|
||||
* @param {Object} dataObjects - Data which contains info on domain requests or a user's permission
|
||||
* Currently returns a dictionary of either:
|
||||
* - "needsAdditionalColumn": If a new column should be displayed
|
||||
* - "hasAdditionalActions": If additional elements need to be added to the Action column
|
||||
* - "UserPortfolioPermissionChoices": A user's portfolio permission choices
|
||||
*/
|
||||
customizeTable(dataObjects){ return {}; }
|
||||
|
@ -406,7 +421,7 @@ export class BaseTable {
|
|||
* Returns either: data.members, data.domains or data.domain_requests
|
||||
* @param {Object} dataObject - The data used to populate the row content
|
||||
* @param {HTMLElement} tbody - The table body to which the new row is appended to
|
||||
* @param {Object} customTableOptions - Additional options for customizing row appearance (ie needsAdditionalColumn)
|
||||
* @param {Object} customTableOptions - Additional options for customizing row appearance (ie hasAdditionalActions)
|
||||
*/
|
||||
addRow(dataObject, tbody, customTableOptions) {
|
||||
throw new Error('addRow must be defined');
|
||||
|
@ -441,6 +456,7 @@ export class BaseTable {
|
|||
const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null;
|
||||
if (!baseUrlValue) return;
|
||||
|
||||
this.tableAnnouncementRegion.innerHTML = '<p>Loading table.</p>';
|
||||
let url = `${baseUrlValue}?${searchParams.toString()}`
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
|
@ -451,7 +467,7 @@ export class BaseTable {
|
|||
}
|
||||
|
||||
// handle the display of proper messaging in the event that no members exist in the list or search returns no results
|
||||
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
|
||||
this.updateDisplay(data, this.tableWrapper, this.noDataTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
|
||||
// identify the DOM element where the list of results will be inserted into the DOM
|
||||
const tbody = this.tableWrapper.querySelector('tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
@ -462,7 +478,6 @@ export class BaseTable {
|
|||
|
||||
let dataObjects = this.getDataObjects(data);
|
||||
let customTableOptions = this.customizeTable(data);
|
||||
|
||||
dataObjects.forEach(dataObject => {
|
||||
this.addRow(dataObject, tbody, customTableOptions);
|
||||
});
|
||||
|
@ -471,6 +486,7 @@ export class BaseTable {
|
|||
this.initCheckboxListeners();
|
||||
|
||||
this.loadModals(data.page, data.total, data.unfiltered_total);
|
||||
this.initializeTooltips();
|
||||
|
||||
// Do not scroll on first page load
|
||||
if (scroll)
|
||||
|
@ -494,8 +510,10 @@ export class BaseTable {
|
|||
|
||||
// Add event listeners to table headers for sorting
|
||||
initializeTableHeaders() {
|
||||
this.tableHeaders.forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
this.tableHeaderSortButtons.forEach(tableHeader => {
|
||||
tableHeader.addEventListener('click', event => {
|
||||
let header = tableHeader.closest('th');
|
||||
if (header) {
|
||||
const sortBy = header.getAttribute('data-sortable');
|
||||
let order = 'asc';
|
||||
// sort order will be ascending, unless the currently sorted column is ascending, and the user
|
||||
|
@ -505,6 +523,9 @@ export class BaseTable {
|
|||
}
|
||||
// load the results with the updated sort
|
||||
this.loadTable(1, sortBy, order);
|
||||
} else {
|
||||
console.warn('Issue with DOM');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -568,9 +589,9 @@ export class BaseTable {
|
|||
|
||||
// Reset UI and accessibility
|
||||
resetHeaders() {
|
||||
this.tableHeaders.forEach(header => {
|
||||
this.tableHeaderSortButtons.forEach(headerSortButton => {
|
||||
// Unset sort UI in headers
|
||||
this.unsetHeader(header);
|
||||
this.unsetHeader(headerSortButton);
|
||||
});
|
||||
// Reset the announcement region
|
||||
this.tableAnnouncementRegion.innerHTML = '';
|
||||
|
|
|
@ -52,26 +52,8 @@ export class DomainRequestsTable extends BaseTable {
|
|||
// Manage "export as CSV" visibility for domain requests
|
||||
this.toggleExportButton(data.domain_requests);
|
||||
|
||||
let needsDeleteColumn = data.domain_requests.some(request => request.is_deletable);
|
||||
|
||||
// Remove existing delete th and td if they exist
|
||||
let existingDeleteTh = document.querySelector('.delete-header');
|
||||
if (!needsDeleteColumn) {
|
||||
if (existingDeleteTh)
|
||||
existingDeleteTh.remove();
|
||||
} else {
|
||||
if (!existingDeleteTh) {
|
||||
const delheader = document.createElement('th');
|
||||
delheader.setAttribute('scope', 'col');
|
||||
delheader.setAttribute('role', 'columnheader');
|
||||
delheader.setAttribute('class', 'delete-header width-5');
|
||||
delheader.innerHTML = `
|
||||
<span class="usa-sr-only">Delete Action</span>`;
|
||||
let tableHeaderRow = this.tableWrapper.querySelector('thead tr');
|
||||
tableHeaderRow.appendChild(delheader);
|
||||
}
|
||||
}
|
||||
return { 'needsAdditionalColumn': needsDeleteColumn };
|
||||
let isDeletable = data.domain_requests.some(request => request.is_deletable);
|
||||
return { 'hasAdditionalActions': isDeletable };
|
||||
}
|
||||
|
||||
addRow(dataObject, tbody, customTableOptions) {
|
||||
|
@ -89,6 +71,7 @@ export class DomainRequestsTable extends BaseTable {
|
|||
|
||||
let markupCreatorRow = '';
|
||||
|
||||
|
||||
if (this.portfolioValue) {
|
||||
markupCreatorRow = `
|
||||
<td>
|
||||
|
@ -98,7 +81,7 @@ export class DomainRequestsTable extends BaseTable {
|
|||
}
|
||||
|
||||
if (request.is_deletable) {
|
||||
// 1st path: Just a modal trigger in any screen size for non-org users
|
||||
// 1st path (non-org): Just a modal trigger in any screen size for non-org users
|
||||
modalTrigger = `
|
||||
<a
|
||||
role="button"
|
||||
|
@ -116,7 +99,7 @@ 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) {
|
||||
|
||||
// 2nd path: Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
@ -133,15 +116,17 @@ export class DomainRequestsTable extends BaseTable {
|
|||
<td data-label="Status">
|
||||
${request.status}
|
||||
</td>
|
||||
<td>
|
||||
<a href="${actionUrl}">
|
||||
<td class="${ this.portfolioValue ? '' : "width-quarter"}">
|
||||
<div class="tablet:display-flex tablet:flex-row">
|
||||
<a href="${actionUrl}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${request.svg_icon}"></use>
|
||||
</svg>
|
||||
${actionLabel} <span class="usa-sr-only">${request.requested_domain ? request.requested_domain : 'New domain request'}</span>
|
||||
</a>
|
||||
${customTableOptions.hasAdditionalActions ? modalTrigger : ''}
|
||||
</div>
|
||||
</td>
|
||||
${customTableOptions.needsAdditionalColumn ? '<td>'+modalTrigger+'</td>' : ''}
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
if (request.is_deletable) DomainRequestsTable.addDomainRequestsModal(request.requested_domain, request.id, request.created_at, tbody);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { BaseTable } from './table-base.js';
|
||||
import { uswdsInitializeTooltips } from './helpers-uswds.js';
|
||||
|
||||
export class DomainsTable extends BaseTable {
|
||||
|
||||
|
@ -31,6 +32,9 @@ export class DomainsTable extends BaseTable {
|
|||
</td>
|
||||
`
|
||||
}
|
||||
const isExpiring = domain.state_display === "Expiring soon"
|
||||
const iconType = isExpiring ? "error_outline" : "info_outline";
|
||||
const iconColor = isExpiring ? "text-secondary-vivid" : "text-accent-cool"
|
||||
row.innerHTML = `
|
||||
<th scope="row" role="rowheader" data-label="Domain name">
|
||||
${domain.name}
|
||||
|
@ -41,18 +45,18 @@ export class DomainsTable extends BaseTable {
|
|||
<td data-label="Status">
|
||||
${domain.state_display}
|
||||
<svg
|
||||
class="usa-icon usa-tooltip usa-tooltip--registrar text-middle margin-bottom-05 text-accent-cool no-click-outline-and-cursor-help"
|
||||
class="usa-icon usa-tooltip usa-tooltip--registrar text-middle margin-bottom-05 ${iconColor} no-click-outline-and-cursor-help"
|
||||
data-position="top"
|
||||
title="${domain.get_state_help_text}"
|
||||
focusable="true"
|
||||
aria-label="${domain.get_state_help_text}"
|
||||
role="tooltip"
|
||||
>
|
||||
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
|
||||
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#${iconType}"></use>
|
||||
</svg>
|
||||
</td>
|
||||
${markupForSuborganizationRow}
|
||||
<td>
|
||||
<td class="${ this.portfolioValue ? '' : "width-quarter"}">
|
||||
<a href="${actionUrl}">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${domain.svg_icon}"></use>
|
||||
|
@ -63,6 +67,9 @@ export class DomainsTable extends BaseTable {
|
|||
`;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
initializeTooltips() {
|
||||
uswdsInitializeTooltips();
|
||||
}
|
||||
}
|
||||
|
||||
export function initDomainsTable() {
|
||||
|
@ -77,3 +84,30 @@ export function initDomainsTable() {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
// For clicking the "Expiring" checkbox
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const expiringLink = document.getElementById('link-expiring-domains');
|
||||
|
||||
if (expiringLink) {
|
||||
// Grab the selection for the status filter by
|
||||
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
|
||||
|
||||
expiringLink.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
// Loop through all statuses
|
||||
statusCheckboxes.forEach(checkbox => {
|
||||
// To find the for checkbox for "Expiring soon"
|
||||
if (checkbox.value === "expiring") {
|
||||
// If the checkbox is not already checked, check it
|
||||
if (!checkbox.checked) {
|
||||
checkbox.checked = true;
|
||||
// Do the checkbox action
|
||||
let event = new Event('change');
|
||||
checkbox.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import { BaseTable } from './table-base.js';
|
||||
import { hideElement, showElement } from './helpers.js';
|
||||
|
||||
/**
|
||||
* EditMemberDomainsTable is used for PortfolioMember and PortfolioInvitedMember
|
||||
|
@ -18,8 +19,14 @@ export class EditMemberDomainsTable extends BaseTable {
|
|||
this.initialDomainAssignmentsOnlyMember = []; // list of initially assigned domains which are readonly
|
||||
this.addedDomains = []; // list of domains added to member
|
||||
this.removedDomains = []; // list of domains removed from member
|
||||
this.editModeContainer = document.getElementById('domain-assignments-edit-view');
|
||||
this.readonlyModeContainer = document.getElementById('domain-assignments-readonly-view');
|
||||
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();
|
||||
this.initCancelEditDomainAssignmentButton();
|
||||
this.initEventListeners();
|
||||
}
|
||||
getBaseUrl() {
|
||||
return document.getElementById("get_member_domains_json_url");
|
||||
|
@ -55,6 +62,14 @@ export class EditMemberDomainsTable extends BaseTable {
|
|||
getSearchParams(page, sortBy, order, searchTerm, status, portfolio) {
|
||||
let searchParams = super.getSearchParams(page, sortBy, order, searchTerm, status, portfolio);
|
||||
// Add checkedDomains to searchParams
|
||||
let checkedDomains = this.getCheckedDomains();
|
||||
// Append updated checkedDomain IDs to searchParams
|
||||
if (checkedDomains.length > 0) {
|
||||
searchParams.append("checkedDomainIds", checkedDomains.join(","));
|
||||
}
|
||||
return searchParams;
|
||||
}
|
||||
getCheckedDomains() {
|
||||
// Clone the initial domains to avoid mutating them
|
||||
let checkedDomains = [...this.initialDomainAssignments];
|
||||
// Add IDs from addedDomains that are not already in checkedDomains
|
||||
|
@ -70,11 +85,7 @@ export class EditMemberDomainsTable extends BaseTable {
|
|||
checkedDomains.splice(index, 1);
|
||||
}
|
||||
});
|
||||
// Append updated checkedDomain IDs to searchParams
|
||||
if (checkedDomains.length > 0) {
|
||||
searchParams.append("checkedDomainIds", checkedDomains.join(","));
|
||||
}
|
||||
return searchParams;
|
||||
return checkedDomains
|
||||
}
|
||||
addRow(dataObject, tbody, customTableOptions) {
|
||||
const domain = dataObject;
|
||||
|
@ -92,8 +103,9 @@ export class EditMemberDomainsTable extends BaseTable {
|
|||
disabled = true;
|
||||
}
|
||||
|
||||
// uses margin-right-neg-5 as a hack to increase the text-wrapping width on this table
|
||||
row.innerHTML = `
|
||||
<td data-label="Selection" data-sort-value="0" class="padding-right-105">
|
||||
<th scope="row" role="rowheader" data-label="Selection" data-sort-value="0" class="padding-right-105">
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
|
@ -101,6 +113,7 @@ export class EditMemberDomainsTable extends BaseTable {
|
|||
type="checkbox"
|
||||
name="${domain.name}"
|
||||
value="${domain.id}"
|
||||
aria-label="${domain.name}"
|
||||
${checked ? 'checked' : ''}
|
||||
${disabled ? 'disabled' : ''}
|
||||
/>
|
||||
|
@ -108,10 +121,10 @@ export class EditMemberDomainsTable extends BaseTable {
|
|||
<span class="sr-only">${domain.id}</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
</th>
|
||||
<td data-label="Domain name">
|
||||
${domain.name}
|
||||
${disabled ? '<span class="display-block margin-top-05 text-gray-50">Domains must have one domain manager. To unassign this member, the domain needs another domain manager.</span>' : ''}
|
||||
${disabled ? '<span class="display-block margin-top-05 text-gray-50 margin-right-neg-5">Domains must have one domain manager. To unassign this member, the domain needs another domain manager.</span>' : ''}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
@ -218,6 +231,127 @@ 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');
|
||||
let removedDomainsCopy = [...this.removedDomains].sort((a, b) => a.name.localeCompare(b.name));
|
||||
removedDomainsCopy.forEach(removedDomain => {
|
||||
const removedDomainListItem = document.createElement('li');
|
||||
removedDomainListItem.textContent = removedDomain.name; // Use textContent for security
|
||||
unassignedDomainsList.appendChild(removedDomainListItem);
|
||||
});
|
||||
|
||||
// Create assigned domains list
|
||||
const assignedDomainsList = document.createElement('ul');
|
||||
assignedDomainsList.classList.add('usa-list', 'usa-list--unstyled');
|
||||
let addedDomainsCopy = [...this.addedDomains].sort((a, b) => a.name.localeCompare(b.name));
|
||||
addedDomainsCopy.forEach(addedDomain => {
|
||||
const addedDomainListItem = document.createElement('li');
|
||||
addedDomainListItem.textContent = addedDomain.name; // Use textContent for security
|
||||
assignedDomainsList.appendChild(addedDomainListItem);
|
||||
});
|
||||
|
||||
// Get the summary container
|
||||
const domainAssignmentSummary = document.getElementById('domain-assignments-summary');
|
||||
|
||||
// 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';
|
||||
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);
|
||||
}
|
||||
|
||||
showReadonlyMode() {
|
||||
this.updateReadonlyDisplay();
|
||||
hideElement(this.editModeContainer);
|
||||
showElement(this.readonlyModeContainer);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
showEditMode() {
|
||||
hideElement(this.readonlyModeContainer);
|
||||
showElement(this.editModeContainer);
|
||||
}
|
||||
|
||||
submitChanges() {
|
||||
let memberDomainsEditForm = document.getElementById("member-domains-edit-form");
|
||||
if (memberDomainsEditForm) {
|
||||
// Serialize data to send
|
||||
const addedDomainIds = this.addedDomains.map(domain => domain.id);
|
||||
const addedDomainsInput = document.createElement('input');
|
||||
addedDomainsInput.type = 'hidden';
|
||||
addedDomainsInput.name = 'added_domains'; // Backend will use this key to retrieve data
|
||||
addedDomainsInput.value = JSON.stringify(addedDomainIds); // Stringify the array
|
||||
|
||||
const removedDomainsIds = this.removedDomains.map(domain => domain.id);
|
||||
const removedDomainsInput = document.createElement('input');
|
||||
removedDomainsInput.type = 'hidden';
|
||||
removedDomainsInput.name = 'removed_domains'; // Backend will use this key to retrieve data
|
||||
removedDomainsInput.value = JSON.stringify(removedDomainsIds); // Stringify the array
|
||||
|
||||
// Append input to the form
|
||||
memberDomainsEditForm.appendChild(addedDomainsInput);
|
||||
memberDomainsEditForm.appendChild(removedDomainsInput);
|
||||
|
||||
memberDomainsEditForm.submit();
|
||||
}
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
if (this.reviewButton) {
|
||||
this.reviewButton.addEventListener('click', () => {
|
||||
this.showReadonlyMode();
|
||||
});
|
||||
} else {
|
||||
console.warn('Missing DOM element. Expected element with id review-domain-assignments');
|
||||
}
|
||||
|
||||
if (this.backButton) {
|
||||
this.backButton.addEventListener('click', () => {
|
||||
this.showEditMode();
|
||||
});
|
||||
} else {
|
||||
console.warn('Missing DOM element. Expected element with id back-to-edit-domain-assignments');
|
||||
}
|
||||
|
||||
if (this.saveButton) {
|
||||
this.saveButton.addEventListener('click', () => {
|
||||
this.submitChanges();
|
||||
});
|
||||
} else {
|
||||
console.warn('Missing DOM element. Expected element with id save-domain-assignments');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initEditMemberDomainsTable() {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
import { showElement, hideElement } from './helpers.js';
|
||||
import { BaseTable } from './table-base.js';
|
||||
|
||||
export class MemberDomainsTable extends BaseTable {
|
||||
|
@ -18,13 +19,37 @@ export class MemberDomainsTable extends BaseTable {
|
|||
const domain = dataObject;
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td scope="row" data-label="Domain name">
|
||||
<th scope="row" role="rowheader" data-label="Domain name">
|
||||
${domain.name}
|
||||
</td>
|
||||
</th>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
|
||||
updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
|
||||
const { unfiltered_total, total } = data;
|
||||
const searchSection = document.getElementById('edit-member-domains__search');
|
||||
if (!searchSection) console.warn('MemberDomainsTable updateDisplay expected an element with id edit-member-domains__search but none was found');
|
||||
if (unfiltered_total) {
|
||||
showElement(searchSection);
|
||||
if (total) {
|
||||
showElement(dataWrapper);
|
||||
hideElement(noSearchResultsWrapper);
|
||||
hideElement(noDataWrapper);
|
||||
this.tableAnnouncementRegion.innerHTML = '';
|
||||
} else {
|
||||
hideElement(dataWrapper);
|
||||
showElement(noSearchResultsWrapper);
|
||||
hideElement(noDataWrapper);
|
||||
this.tableAnnouncementRegion.innerHTML = this.noSearchResultsWrapper.innerHTML;
|
||||
}
|
||||
} else {
|
||||
hideElement(searchSection);
|
||||
hideElement(dataWrapper);
|
||||
hideElement(noSearchResultsWrapper);
|
||||
showElement(noDataWrapper);
|
||||
this.tableAnnouncementRegion.innerHTML = this.noDataWrapper.innerHTML;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function initMemberDomainsTable() {
|
||||
|
|
|
@ -61,7 +61,7 @@ export class MembersTable extends BaseTable {
|
|||
tableHeaderRow.appendChild(extraActionsHeader);
|
||||
}
|
||||
return {
|
||||
'needsAdditionalColumn': hasEditPermission,
|
||||
'hasAdditionalActions': hasEditPermission,
|
||||
'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices
|
||||
};
|
||||
}
|
||||
|
@ -78,13 +78,12 @@ export class MembersTable extends BaseTable {
|
|||
const num_domains = member.domain_urls.length;
|
||||
const last_active = this.handleLastActive(member.last_active);
|
||||
let cancelInvitationButton = member.type === "invitedmember" ? "Cancel invitation" : "Remove member";
|
||||
const kebabHTML = customTableOptions.needsAdditionalColumn ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member.name}`): '';
|
||||
const kebabHTML = customTableOptions.hasAdditionalActions ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `Expand for more options for ${member.name}`): '';
|
||||
|
||||
const row = document.createElement('tr');
|
||||
|
||||
let admin_tagHTML = ``;
|
||||
if (member.is_admin)
|
||||
admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary">Admin</span>`
|
||||
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);
|
||||
|
@ -99,7 +98,8 @@ export class MembersTable extends BaseTable {
|
|||
type="button"
|
||||
class="usa-button--show-more-button usa-button usa-button--unstyled display-block margin-top-1"
|
||||
data-for=${unique_id}
|
||||
aria-label="Expand for additional information"
|
||||
aria-label="Expand for additional information for ${member.member_display}"
|
||||
aria-label-placeholder="${member.member_display}"
|
||||
>
|
||||
<span>Expand</span>
|
||||
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
|
@ -129,7 +129,7 @@ export class MembersTable extends BaseTable {
|
|||
${member.action_label} <span class="usa-sr-only">${member.name}</span>
|
||||
</a>
|
||||
</td>
|
||||
${customTableOptions.needsAdditionalColumn ? '<td>'+kebabHTML+'</td>' : ''}
|
||||
${customTableOptions.hasAdditionalActions ? '<td>'+kebabHTML+'</td>' : ''}
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
if (domainsHTML || permissionsHTML) {
|
||||
|
@ -137,7 +137,7 @@ export class MembersTable extends BaseTable {
|
|||
}
|
||||
// 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
|
||||
if (customTableOptions.needsAdditionalColumn) MembersTable.addMemberModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row);
|
||||
if (customTableOptions.hasAdditionalActions) MembersTable.addMemberDeleteModal(num_domains, member.email || member.name || "Samwise Gamgee", member_delete_url, unique_id, row);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -166,13 +166,27 @@ export class MembersTable extends BaseTable {
|
|||
spanElement.textContent = 'Close';
|
||||
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
|
||||
buttonParentRow.classList.add('hide-td-borders');
|
||||
toggleButton.setAttribute('aria-label', 'Close additional information');
|
||||
|
||||
let ariaLabelText = "Close additional information";
|
||||
let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder");
|
||||
if (ariaLabelPlaceholder) {
|
||||
ariaLabelText = `Close additional information for ${ariaLabelPlaceholder}`;
|
||||
}
|
||||
toggleButton.setAttribute('aria-label', ariaLabelText);
|
||||
|
||||
// Set tabindex for focusable elements in expanded content
|
||||
} else {
|
||||
hideElement(contentDiv);
|
||||
spanElement.textContent = 'Expand';
|
||||
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
|
||||
buttonParentRow.classList.remove('hide-td-borders');
|
||||
toggleButton.setAttribute('aria-label', 'Expand for additional information');
|
||||
|
||||
let ariaLabelText = "Expand for additional information";
|
||||
let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder");
|
||||
if (ariaLabelPlaceholder) {
|
||||
ariaLabelText = `Expand for additional information for ${ariaLabelPlaceholder}`;
|
||||
}
|
||||
toggleButton.setAttribute('aria-label', ariaLabelText);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -245,21 +259,19 @@ export class MembersTable extends BaseTable {
|
|||
// Only generate HTML if the member has one or more assigned domains
|
||||
if (num_domains > 0) {
|
||||
domainsHTML += "<div class='desktop:grid-col-5 margin-bottom-2 desktop:margin-bottom-0'>";
|
||||
domainsHTML += "<h4 class='margin-y-0 text-primary'>Domains assigned</h4>";
|
||||
domainsHTML += `<p class='margin-y-0'>This member is assigned to ${num_domains} domains:</p>`;
|
||||
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 += "<ul class='usa-list usa-list--unstyled margin-y-0'>";
|
||||
|
||||
// Display up to 6 domains with their URLs
|
||||
for (let i = 0; i < num_domains && i < 6; i++) {
|
||||
domainsHTML += `<li><a href="${domain_urls[i]}">${domain_names[i]}</a></li>`;
|
||||
domainsHTML += `<li><a class="font-body-xs" href="${domain_urls[i]}">${domain_names[i]}</a></li>`;
|
||||
}
|
||||
|
||||
domainsHTML += "</ul>";
|
||||
|
||||
// If there are more than 6 domains, display a "View assigned domains" link
|
||||
if (num_domains >= 6) {
|
||||
domainsHTML += `<p><a href="${action_url}/domains">View assigned domains</a></p>`;
|
||||
}
|
||||
domainsHTML += `<p class="font-body-xs"><a href="${action_url}/domains">View assigned domains</a></p>`;
|
||||
|
||||
domainsHTML += "</div>";
|
||||
}
|
||||
|
@ -378,34 +390,37 @@ export class MembersTable extends BaseTable {
|
|||
generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) {
|
||||
let permissionsHTML = '';
|
||||
|
||||
// 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
|
||||
if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><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>";
|
||||
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>`;
|
||||
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domains:</strong> Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>";
|
||||
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>`;
|
||||
}
|
||||
|
||||
// Check request-related permissions
|
||||
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domain requests:</strong> Can view all organization domain requests. Can create domain requests and modify their own requests.</p>";
|
||||
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>`;
|
||||
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><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>";
|
||||
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>`;
|
||||
}
|
||||
|
||||
// Check member-related permissions
|
||||
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Members:</strong> Can manage members including inviting new members, removing current members, and assigning domains to members.</p>";
|
||||
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>`;
|
||||
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Members (view-only):</strong> Can view all organizational members. Can't manage any members.</p>";
|
||||
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Members (view-only):</strong> Can view all organizational members. Can't manage any members.</p>`;
|
||||
}
|
||||
|
||||
// If no specific permissions are assigned, display a message indicating no additional permissions
|
||||
if (!permissionsHTML) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><b>No additional permissions:</b> There are no additional permissions for this member.</p>";
|
||||
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='margin-y-0 text-primary'>Additional permissions for this member</h4>" + permissionsHTML + "</div>";
|
||||
permissionsHTML = `<div class='desktop:grid-col-7'><h4 class='font-body-xs margin-y-0'>Additional permissions for this member</h4>${permissionsHTML}</div>`;
|
||||
|
||||
return permissionsHTML;
|
||||
}
|
||||
|
@ -417,25 +432,22 @@ export class MembersTable extends BaseTable {
|
|||
* @param {string} submit_delete_url - `${member_type}-${member_id}/delete`
|
||||
* @param {HTMLElement} wrapper_element - The element to which the modal is appended
|
||||
*/
|
||||
static addMemberModal(num_domains, member_email, submit_delete_url, id, wrapper_element) {
|
||||
let modalHeading = '';
|
||||
let modalDescription = '';
|
||||
static addMemberDeleteModal(num_domains, member_email, submit_delete_url, id, wrapper_element) {
|
||||
|
||||
if (num_domains == 0){
|
||||
modalHeading = `Are you sure you want to delete ${member_email}?`;
|
||||
let modalHeading = ``;
|
||||
let modalDescription = ``;
|
||||
|
||||
if (num_domains >= 0){
|
||||
modalHeading = `Are you sure you want to remove ${member_email} from the organization?`;
|
||||
modalDescription = `They will no longer be able to access this organization.
|
||||
This action cannot be undone.`;
|
||||
} else if (num_domains == 1) {
|
||||
modalHeading = `Are you sure you want to delete ${member_email}?`;
|
||||
modalDescription = `<b>${member_email}</b> currently manages ${num_domains} domain in the organization.
|
||||
Removing them from the organization will remove all of their domains. They will no longer be able to
|
||||
access this organization. This action cannot be undone.`;
|
||||
} else if (num_domains > 1) {
|
||||
modalHeading = `Are you sure you want to delete ${member_email}?`;
|
||||
modalDescription = `<b>${member_email}</b> currently manages ${num_domains} domains in the organization.
|
||||
Removing them from the organization will remove all of their domains. They will no longer be able to
|
||||
if (num_domains >= 1)
|
||||
{
|
||||
modalDescription = `<b>${member_email}</b> currently manages ${num_domains} domain${num_domains > 1 ? "s": ""} in the organization.
|
||||
Removing them from the organization will remove them from all of their domains. They will no longer be able to
|
||||
access this organization. This action cannot be undone.`;
|
||||
}
|
||||
}
|
||||
|
||||
const modalSubmit = `
|
||||
<button type="button"
|
||||
|
|
|
@ -40,8 +40,39 @@
|
|||
top: 30px;
|
||||
}
|
||||
|
||||
tr:last-child .usa-accordion--more-actions .usa-accordion__content {
|
||||
// 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 {
|
||||
top: auto;
|
||||
bottom: -10px;
|
||||
right: 30px;
|
||||
}
|
||||
|
||||
// A CSS only show-more/show-less based on usa-accordion
|
||||
.usa-accordion--show-more {
|
||||
width: auto;
|
||||
.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;
|
||||
background-color: transparent;
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
.usa-accordion__button[aria-expanded=true] .expand-more {
|
||||
display: inline-block;
|
||||
}
|
||||
.usa-accordion__button[aria-expanded=true] .expand-less {
|
||||
display: none;
|
||||
}
|
||||
.usa-accordion__button[aria-expanded=false] .expand-more {
|
||||
display: none;
|
||||
}
|
||||
.usa-accordion__button[aria-expanded=false] .expand-less {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -176,10 +176,19 @@ html[data-theme="dark"] {
|
|||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
// Reset the USWDS styles for alerts
|
||||
@include at-media(desktop) {
|
||||
.dashboard .usa-alert__body--widescreen {
|
||||
padding-left: 4rem !important;
|
||||
}
|
||||
|
||||
.dashboard .usa-alert__body--widescreen::before {
|
||||
left: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#branding h1,
|
||||
h1, h2, h3,
|
||||
.dashboard h1, .dashboard h2, .dashboard h3,
|
||||
.module h2 {
|
||||
font-weight: font-weight('bold');
|
||||
}
|
||||
|
@ -342,6 +351,40 @@ div#content > h2 {
|
|||
}
|
||||
}
|
||||
|
||||
.module {
|
||||
.margin-left-0 {
|
||||
margin-left: 0;
|
||||
}
|
||||
.margin-top-0 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.padding-left-0 {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-list-inline {
|
||||
li {
|
||||
float: left;
|
||||
padding-top: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
li:not(:last-child)::after {
|
||||
content: ",";
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
.margin-y-0 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.padding-y-0 {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Fixes a display issue where the list was entirely white, or had too much whitespace
|
||||
.select2-dropdown {
|
||||
display: inline-grid !important;
|
||||
|
@ -473,10 +516,6 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too
|
|||
max-width: 68ex;
|
||||
}
|
||||
|
||||
.usa-summary-box__dhs-color {
|
||||
color: $dhs-blue-70;
|
||||
}
|
||||
|
||||
details.dja-detail-table {
|
||||
display: inline-table;
|
||||
background-color: var(--body-bg);
|
||||
|
@ -769,18 +808,6 @@ div.dja__model-description{
|
|||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
//-- Override some styling for the USWDS summary box (per design quidance for ticket #2055
|
||||
.usa-summary-box {
|
||||
background: #{$dhs-blue-10};
|
||||
border-color: #{$dhs-blue-30};
|
||||
max-width: 72ex;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.usa-summary-box h3 {
|
||||
color: #{$dhs-blue-60};
|
||||
}
|
||||
|
||||
.module caption, .inline-group h2 {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
@ -886,14 +913,6 @@ ul.add-list-reset {
|
|||
font-size: 14px;
|
||||
}
|
||||
|
||||
.domain-name-wrap {
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow: visible;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.organization-admin-label {
|
||||
font-weight: 600;
|
||||
font-size: .8125rem;
|
||||
|
|
|
@ -1,21 +1,18 @@
|
|||
@use "uswds-core" as *;
|
||||
@use "base" as *;
|
||||
|
||||
// Fixes some font size disparities with the Figma
|
||||
// for usa-alert alert elements
|
||||
.usa-alert {
|
||||
.usa-alert__heading.larger-font-sizing {
|
||||
font-size: units(3);
|
||||
}
|
||||
}
|
||||
|
||||
.usa-alert__text.measure-none {
|
||||
max-width: measure(none);
|
||||
}
|
||||
/*----------------
|
||||
Alert Layout
|
||||
-----------------*/
|
||||
|
||||
// The icon was off center for some reason
|
||||
// Fixes that issue
|
||||
@media (min-width: 64em){
|
||||
@include at-media(desktop) {
|
||||
// NOTE: !important is used because _font.scss overrides this
|
||||
.usa-alert__body {
|
||||
max-width: $widescreen-max-width !important;
|
||||
}
|
||||
.usa-alert--warning{
|
||||
.usa-alert__body::before {
|
||||
left: 1rem !important;
|
||||
|
@ -24,13 +21,29 @@
|
|||
.usa-alert__body.margin-left-1 {
|
||||
margin-left: 0.5rem!important;
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: !important is used because _font.scss overrides this
|
||||
.usa-alert__body--widescreen::before {
|
||||
left: 4rem !important;
|
||||
}
|
||||
.usa-alert__body--widescreen {
|
||||
max-width: $widescreen-max-width !important;
|
||||
padding-left: 7rem!important;
|
||||
}
|
||||
}
|
||||
|
||||
/*----------------
|
||||
Alert Fonts
|
||||
-----------------*/
|
||||
// Fixes some font size disparities with the Figma
|
||||
// for usa-alert alert elements
|
||||
.usa-alert {
|
||||
.usa-alert__heading.larger-font-sizing {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/*----------------
|
||||
Alert Coloring
|
||||
-----------------*/
|
||||
.usa-site-alert--hot-pink {
|
||||
.usa-alert {
|
||||
background-color: $hot-pink;
|
||||
|
@ -47,3 +60,8 @@
|
|||
background-color: color('base-darkest');
|
||||
}
|
||||
}
|
||||
|
||||
// Override the specificity of USWDS css to enable no max width on admin alerts
|
||||
.usa-alert__body.maxw-none {
|
||||
max-width: none;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
@use "cisa_colors" as *;
|
||||
|
||||
$widescreen-max-width: 1920px;
|
||||
$widescreen-x-padding: 4.5rem;
|
||||
|
||||
$hot-pink: #FFC3F9;
|
||||
|
||||
/* Styles for making visible to screen reader / AT users only. */
|
||||
|
@ -39,7 +41,8 @@ body {
|
|||
padding-top: units(5)!important;
|
||||
}
|
||||
|
||||
#wrapper.dashboard--grey-1 {
|
||||
#wrapper.dashboard--grey-1,
|
||||
.bg-gray-1 {
|
||||
background-color: color('gray-1');
|
||||
}
|
||||
|
||||
|
@ -56,7 +59,6 @@ body {
|
|||
}
|
||||
|
||||
h2 {
|
||||
color: color('primary-dark');
|
||||
margin-top: units(2);
|
||||
margin-bottom: units(2);
|
||||
}
|
||||
|
@ -127,16 +129,6 @@ grid column to the max-width of the searchbar, which was calculated to be 33rem.
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dotgov-status-box {
|
||||
background-color: color('primary-lightest');
|
||||
border-color: color('accent-cool-lighter');
|
||||
}
|
||||
|
||||
.dotgov-status-box--action-need {
|
||||
background-color: color('warning-lighter');
|
||||
border-color: color('warning');
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid color('primary-darker');
|
||||
}
|
||||
|
@ -149,6 +141,11 @@ footer {
|
|||
color: color('primary');
|
||||
}
|
||||
|
||||
.usa-radio {
|
||||
margin-top: 1rem;
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
// workaround for underlining abbr element
|
||||
border-bottom: none;
|
||||
|
@ -220,14 +217,6 @@ abbr[title] {
|
|||
max-width: 23ch;
|
||||
}
|
||||
|
||||
.ellipsis--30 {
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
.ellipsis--50 {
|
||||
max-width: 50ch;
|
||||
}
|
||||
|
||||
.vertical-align-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
@ -247,6 +236,15 @@ abbr[title] {
|
|||
max-width: $widescreen-max-width;
|
||||
}
|
||||
|
||||
// This is used in cases where we want to align content to widescreen margins
|
||||
// but we don't want the content itself to have widescreen widths
|
||||
@include at-media(desktop) {
|
||||
.padding-x--widescreen {
|
||||
padding-left: $widescreen-x-padding !important;
|
||||
padding-right: $widescreen-x-padding !important;
|
||||
}
|
||||
}
|
||||
|
||||
.margin-right-neg-4px {
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
@ -255,9 +253,25 @@ abbr[title] {
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
.string-wrap {
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow: visible;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
//Icon size adjustment used by buttons and form errors
|
||||
.usa-icon.usa-icon--large {
|
||||
margin: 0;
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
}
|
||||
|
||||
.maxw-fit-content {
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.width-quarter {
|
||||
width: 25%;
|
||||
}
|
||||
|
|
|
@ -236,13 +236,6 @@ a.withdraw_outline:active {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.dotgov-table a
|
||||
a .usa-icon,
|
||||
.usa-button--with-icon .usa-icon {
|
||||
height: 1.3em;
|
||||
width: 1.3em;
|
||||
}
|
||||
|
||||
// Red, for delete buttons
|
||||
// Used on: All delete buttons
|
||||
// Note: Can be simplified by adding text-secondary to delete anchors in tables
|
||||
|
@ -253,6 +246,10 @@ a.text-secondary:hover {
|
|||
color: $theme-color-error;
|
||||
}
|
||||
|
||||
.usa-button.usa-button--secondary {
|
||||
background-color: $theme-color-error;
|
||||
}
|
||||
|
||||
.usa-button--show-more-button {
|
||||
font-size: size('ui', 'xs');
|
||||
text-decoration: none;
|
||||
|
|
|
@ -6,3 +6,21 @@
|
|||
.usa-identifier__container--widescreen {
|
||||
max-width: $widescreen-max-width !important;
|
||||
}
|
||||
|
||||
|
||||
// NOTE: !important is used because we are overriding default
|
||||
// USWDS paddings in a few locations
|
||||
@include at-media(desktop) {
|
||||
.grid-container--widescreen {
|
||||
padding-left: $widescreen-x-padding !important;
|
||||
padding-right: $widescreen-x-padding !important;
|
||||
}
|
||||
}
|
||||
|
||||
// matches max-width to equal the max-width of .grid-container
|
||||
// used to trick the eye into thinking we have left-aligned a
|
||||
// regular grid-container within a widescreen (see instances
|
||||
// where is_widescreen_centered is used in the html).
|
||||
.max-width--grid-container {
|
||||
max-width: 960px;
|
||||
}
|
|
@ -1,7 +1,14 @@
|
|||
@use "uswds-core" as *;
|
||||
@use "cisa_colors" as *;
|
||||
@use "typography" as *;
|
||||
|
||||
// Normalize typography in forms
|
||||
.usa-form,
|
||||
.usa-form fieldset {
|
||||
font-size: 1rem;
|
||||
.usa-legend {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
.usa-form .usa-button {
|
||||
margin-top: units(3);
|
||||
}
|
||||
|
@ -69,12 +76,6 @@ legend.float-left-tablet + button.float-right-tablet {
|
|||
}
|
||||
}
|
||||
|
||||
.read-only-label {
|
||||
@extend .h4--sm-05;
|
||||
font-weight: bold;
|
||||
color: color('primary-dark');
|
||||
}
|
||||
|
||||
.read-only-value {
|
||||
margin-top: units(0);
|
||||
.bg-gray-1 .usa-radio {
|
||||
background: color('gray-1');
|
||||
}
|
||||
|
|
|
@ -110,8 +110,8 @@
|
|||
}
|
||||
}
|
||||
.usa-nav__secondary {
|
||||
// I don't know why USWDS has this at 2 rem, which puts it out of alignment
|
||||
right: 3rem;
|
||||
right: 1rem;
|
||||
padding-right: $widescreen-x-padding;
|
||||
color: color('white');
|
||||
bottom: 4.3rem;
|
||||
.usa-nav-link,
|
||||
|
|
5
src/registrar/assets/src/sass/_theme/_modals.scss
Normal file
5
src/registrar/assets/src/sass/_theme/_modals.scss
Normal file
|
@ -0,0 +1,5 @@
|
|||
@use "uswds-core" as *;
|
||||
|
||||
.usa-modal__main {
|
||||
padding: 0 2rem 2rem;
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
@use "uswds-core" as *;
|
||||
@use "typography" as *;
|
||||
|
||||
.register-form-step > h1 {
|
||||
//align to top of sidebar on first page of the form
|
||||
|
@ -12,11 +11,7 @@
|
|||
margin-top: units(1);
|
||||
}
|
||||
|
||||
// register-form-review-header is used on the summary page and
|
||||
// should not be styled like the register form headers
|
||||
.register-form-step h3 {
|
||||
color: color('primary-dark');
|
||||
letter-spacing: $letter-space--xs;
|
||||
.register-form-step h3:not(.margin-top-05) {
|
||||
margin-top: units(3);
|
||||
margin-bottom: 0;
|
||||
|
||||
|
@ -25,15 +20,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.register-form-review-header {
|
||||
color: color('primary-dark');
|
||||
margin-top: units(2);
|
||||
margin-bottom: 0;
|
||||
font-weight: font-weight('semibold');
|
||||
// The units mixin can only get us close, so it's between
|
||||
// hardcoding the value and using in markup
|
||||
font-size: 16.96px;
|
||||
}
|
||||
|
||||
.register-form-step h4 {
|
||||
margin-bottom: 0;
|
||||
|
@ -80,19 +66,3 @@
|
|||
margin-top: 0;
|
||||
margin-bottom: units(0.5);
|
||||
}
|
||||
|
||||
.review__step__title a:visited {
|
||||
color: color('primary');
|
||||
}
|
||||
|
||||
.review__step__name {
|
||||
color: color('primary-dark');
|
||||
font-weight: font-weight('semibold');
|
||||
margin-bottom: units(0.5);
|
||||
}
|
||||
|
||||
.review__step__subheading {
|
||||
color: color('primary-dark');
|
||||
font-weight: font-weight('semibold');
|
||||
margin-bottom: units(0.5);
|
||||
}
|
||||
|
|
15
src/registrar/assets/src/sass/_theme/_summary-box.scss
Normal file
15
src/registrar/assets/src/sass/_theme/_summary-box.scss
Normal file
|
@ -0,0 +1,15 @@
|
|||
@use "uswds-core" as *;
|
||||
|
||||
.usa-summary-box {
|
||||
background-color: color('primary-lightest');
|
||||
border-color: color('accent-cool-lighter');
|
||||
}
|
||||
|
||||
.usa-summary-box--action-needed {
|
||||
background-color: color('warning-lighter');
|
||||
border-color: color('warning');
|
||||
}
|
||||
|
||||
.usa-summary-box__heading {
|
||||
font-weight: bold;
|
||||
}
|
|
@ -41,6 +41,13 @@ 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 {
|
||||
width: 100%;
|
||||
|
||||
|
@ -56,11 +63,10 @@ th {
|
|||
border: none;
|
||||
}
|
||||
|
||||
tr:not(.hide-td-borders) {
|
||||
td, th {
|
||||
tr:not(.hide-td-borders):not(:last-child) td,
|
||||
tr:not(.hide-td-borders):not(:last-child) th {
|
||||
border-bottom: 1px solid color('base-lighter');
|
||||
}
|
||||
}
|
||||
|
||||
thead th {
|
||||
color: color('primary-darker');
|
||||
|
@ -88,8 +94,36 @@ th {
|
|||
}
|
||||
|
||||
@include at-media(tablet-lg) {
|
||||
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
|
||||
th[data-sortable] .usa-table__header__button {
|
||||
right: auto;
|
||||
|
||||
&[aria-sort=ascending],
|
||||
&[aria-sort=descending],
|
||||
&:not([aria-sort]) {
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dotgov-table--cell-padding-2 {
|
||||
td, th {
|
||||
padding: units(2);
|
||||
}
|
||||
}
|
||||
|
||||
.usa-table--striped tbody tr:nth-child(odd) th,
|
||||
.usa-table--striped tbody tr:nth-child(odd) td {
|
||||
background-color: color('primary-lightest');
|
||||
}
|
||||
|
||||
.usa-table--bg-transparent {
|
||||
td, thead th {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.usa-table--full-borderless td,
|
||||
.usa-table--full-borderless th {
|
||||
border: none !important;
|
||||
}
|
||||
|
|
3
src/registrar/assets/src/sass/_theme/_tags.scss
Normal file
3
src/registrar/assets/src/sass/_theme/_tags.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.usa-tag {
|
||||
text-transform: none;
|
||||
}
|
|
@ -66,9 +66,9 @@
|
|||
text-align: center;
|
||||
font-size: inherit; //inherit tooltip fontsize of .93rem
|
||||
max-width: fit-content;
|
||||
display: block;
|
||||
@include at-media('desktop') {
|
||||
width: 70vw;
|
||||
}
|
||||
display: block;
|
||||
}
|
||||
}
|
|
@ -10,33 +10,43 @@ address,
|
|||
max-width: measure(5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
h1:not(.usa-alert__heading),
|
||||
// .module h2 excludes headers in DJA
|
||||
h2:not(.usa-alert__heading, .module h2),
|
||||
h3:not(.usa-alert__heading),
|
||||
h4:not(.usa-alert__heading),
|
||||
h5:not(.usa-alert__heading),
|
||||
h6:not(.usa-alert__heading) {
|
||||
color: color('primary-darker');
|
||||
}
|
||||
|
||||
h1, .h1 {
|
||||
font-size: 2.125rem;
|
||||
@include typeset('sans', '2xl', 2);
|
||||
margin: 0 0 units(2);
|
||||
color: color('primary-darker');
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: font-weight('semibold');
|
||||
line-height: line-height('heading', 3);
|
||||
h2, .h2 {
|
||||
line-height: 1.3;
|
||||
margin: units(4) 0 units(1);
|
||||
color: color('primary-darker');
|
||||
}
|
||||
|
||||
.h4--sm-05 {
|
||||
font-size: size('body', 'sm');
|
||||
font-weight: normal;
|
||||
color: color('primary');
|
||||
margin-bottom: units(0.5);
|
||||
h3, .h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: font-weight('semibold');
|
||||
}
|
||||
|
||||
// Normalize typography in forms
|
||||
.usa-form,
|
||||
.usa-form fieldset {
|
||||
font-size: 1rem;
|
||||
h4, .h4 {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25;
|
||||
font-weight: font-weight('semibold');
|
||||
}
|
||||
|
||||
.p--blockquote {
|
||||
padding-left: units(1);
|
||||
border-left: 2px solid color('base-lighter');
|
||||
}
|
||||
|
||||
.font-body-1 {
|
||||
font-size: size('body', 1);
|
||||
}
|
||||
|
|
|
@ -68,6 +68,7 @@ in the form $setting: value,
|
|||
/*---------------------------
|
||||
## Font weights
|
||||
----------------------------*/
|
||||
$theme-font-weight-medium: 400,
|
||||
$theme-font-weight-semibold: 600,
|
||||
|
||||
/*---------------------------
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
@forward "forms";
|
||||
@forward "search";
|
||||
@forward "tooltips";
|
||||
@forward "summary-box";
|
||||
@forward "fieldsets";
|
||||
@forward "alerts";
|
||||
@forward "tables";
|
||||
|
@ -25,6 +26,8 @@
|
|||
@forward "header";
|
||||
@forward "register-form";
|
||||
@forward "containers";
|
||||
@forward "modals";
|
||||
@forward "tags";
|
||||
|
||||
/*--------------------------------------------------
|
||||
--- Admin ---------------------------------*/
|
||||
|
|
|
@ -25,6 +25,7 @@ from typing import Final
|
|||
from botocore.config import Config
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from django.utils.log import ServerFormatter
|
||||
|
||||
# # # ###
|
||||
|
@ -252,7 +253,7 @@ TEMPLATES = [
|
|||
"registrar.context_processors.org_user_status",
|
||||
"registrar.context_processors.add_path_to_context",
|
||||
"registrar.context_processors.portfolio_permissions",
|
||||
"registrar.context_processors.is_widescreen_mode",
|
||||
"registrar.context_processors.is_widescreen_centered",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@ -472,7 +473,11 @@ class JsonFormatter(logging.Formatter):
|
|||
"lineno": record.lineno,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
return json.dumps(log_record)
|
||||
# Capture exception info if it exists
|
||||
if record.exc_info:
|
||||
log_record["exception"] = "".join(traceback.format_exception(*record.exc_info))
|
||||
|
||||
return json.dumps(log_record, ensure_ascii=False)
|
||||
|
||||
|
||||
class JsonServerFormatter(ServerFormatter):
|
||||
|
@ -526,7 +531,7 @@ LOGGING = {
|
|||
"()": JsonFormatter,
|
||||
},
|
||||
},
|
||||
# define where log messages will be sent;
|
||||
# define where log messages will be sent
|
||||
# each logger can have one or more handlers
|
||||
"handlers": {
|
||||
"console": {
|
||||
|
|
|
@ -146,7 +146,7 @@ urlpatterns = [
|
|||
# ),
|
||||
path(
|
||||
"members/new-member/",
|
||||
views.NewMemberView.as_view(),
|
||||
views.PortfolioAddMemberView.as_view(),
|
||||
name="new-member",
|
||||
),
|
||||
path(
|
||||
|
@ -345,6 +345,11 @@ urlpatterns = [
|
|||
views.DomainSecurityEmailView.as_view(),
|
||||
name="domain-security-email",
|
||||
),
|
||||
path(
|
||||
"domain/<int:pk>/renewal",
|
||||
views.DomainRenewalView.as_view(),
|
||||
name="domain-renewal",
|
||||
),
|
||||
path(
|
||||
"domain/<int:pk>/users/add",
|
||||
views.DomainAddUserView.as_view(),
|
||||
|
|
|
@ -69,9 +69,19 @@ 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(),
|
||||
}
|
||||
)
|
||||
|
||||
# Linting: line too long
|
||||
view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio)
|
||||
edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio)
|
||||
|
@ -90,6 +100,7 @@ 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
|
||||
|
||||
|
@ -98,31 +109,21 @@ def portfolio_permissions(request):
|
|||
return portfolio_context
|
||||
|
||||
|
||||
def is_widescreen_mode(request):
|
||||
widescreen_paths = [] # If this list is meant to include specific paths, populate it.
|
||||
portfolio_widescreen_paths = [
|
||||
def is_widescreen_centered(request):
|
||||
include_paths = [
|
||||
"/domains/",
|
||||
"/requests/",
|
||||
"/request/",
|
||||
"/no-organization-requests/",
|
||||
"/no-organization-domains/",
|
||||
"/domain-request/",
|
||||
"/members/",
|
||||
]
|
||||
# widescreen_paths can be a bear as it trickles down sub-urls. exclude_paths gives us a way out.
|
||||
exclude_paths = [
|
||||
"/domains/edit",
|
||||
"members/new-member/",
|
||||
]
|
||||
|
||||
# Check if the current path matches a widescreen path or the root path.
|
||||
is_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/"
|
||||
is_excluded = any(exclude_path in request.path for exclude_path in exclude_paths)
|
||||
|
||||
# Check if the user is an organization user and the path matches portfolio paths.
|
||||
is_portfolio_widescreen = (
|
||||
hasattr(request.user, "is_org_user")
|
||||
and request.user.is_org_user(request)
|
||||
and any(path in request.path for path in portfolio_widescreen_paths)
|
||||
and not any(exclude_path in request.path for exclude_path in exclude_paths)
|
||||
)
|
||||
# Check if the current path matches a path in included_paths or the root path.
|
||||
is_widescreen_centered = any(path in request.path for path in include_paths) or request.path == "/"
|
||||
|
||||
# Return a dictionary with the widescreen mode status.
|
||||
return {"is_widescreen_mode": is_widescreen or is_portfolio_widescreen}
|
||||
return {"is_widescreen_centered": is_widescreen_centered and not is_excluded}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from django.utils import timezone
|
||||
import logging
|
||||
import random
|
||||
|
@ -126,7 +126,22 @@ class DomainRequestFixture:
|
|||
# TODO for a future ticket: Allow for more than just "federal" here
|
||||
request.generic_org_type = request_dict["generic_org_type"] if "generic_org_type" in request_dict else "federal"
|
||||
if request.status != "started":
|
||||
request.last_submitted_date = fake.date()
|
||||
# Generate fake data for first_submitted_date and last_submitted_date
|
||||
# First generate a random date set to be later than 2020 (or something)
|
||||
# (if we just use fake.date() we might get years like 1970 or earlier)
|
||||
earliest_date_allowed = datetime(2020, 1, 1).date()
|
||||
end_date = datetime.today().date() # Today's date (latest allowed date)
|
||||
days_range = (end_date - earliest_date_allowed).days
|
||||
first_submitted_date = earliest_date_allowed + timedelta(days=random.randint(0, days_range)) # nosec
|
||||
|
||||
# Generate a random positive offset to ensure last_submitted_date is later
|
||||
# (Start with 1 to ensure at least 1 day difference)
|
||||
offset_days = random.randint(1, 30) # nosec
|
||||
last_submitted_date = first_submitted_date + timedelta(days=offset_days)
|
||||
|
||||
# Convert back to strings before assigning
|
||||
request.first_submitted_date = first_submitted_date.strftime("%Y-%m-%d")
|
||||
request.last_submitted_date = last_submitted_date.strftime("%Y-%m-%d")
|
||||
request.federal_type = (
|
||||
request_dict["federal_type"]
|
||||
if "federal_type" in request_dict
|
||||
|
@ -308,9 +323,18 @@ class DomainRequestFixture:
|
|||
cls._create_domain_requests(users)
|
||||
|
||||
@classmethod
|
||||
def _create_domain_requests(cls, users):
|
||||
def _create_domain_requests(cls, users): # noqa: C901
|
||||
"""Creates DomainRequests given a list of users."""
|
||||
total_domain_requests_to_make = len(users) # 100000
|
||||
|
||||
# Check if the database is already populated with the desired
|
||||
# number of entries.
|
||||
# (Prevents re-adding more entries to an already populated database,
|
||||
# which happens when restarting Docker src)
|
||||
domain_requests_already_made = DomainRequest.objects.count()
|
||||
|
||||
domain_requests_to_create = []
|
||||
if domain_requests_already_made < total_domain_requests_to_make:
|
||||
for user in users:
|
||||
for request_data in cls.DOMAINREQUESTS:
|
||||
# Prepare DomainRequest objects
|
||||
|
@ -325,6 +349,25 @@ class DomainRequestFixture:
|
|||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
num_additional_requests_to_make = (
|
||||
total_domain_requests_to_make - domain_requests_already_made - len(domain_requests_to_create)
|
||||
)
|
||||
if num_additional_requests_to_make > 0:
|
||||
for _ in range(num_additional_requests_to_make):
|
||||
random_user = random.choice(users) # nosec
|
||||
try:
|
||||
random_request_type = random.choice(cls.DOMAINREQUESTS) # nosec
|
||||
# Prepare DomainRequest objects
|
||||
domain_request = DomainRequest(
|
||||
creator=random_user,
|
||||
organization_name=random_request_type["organization_name"],
|
||||
)
|
||||
cls._set_non_foreign_key_fields(domain_request, random_request_type)
|
||||
cls._set_foreign_key_fields(domain_request, random_request_type, random_user)
|
||||
domain_requests_to_create.append(domain_request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating random domain request: {e}")
|
||||
|
||||
# Bulk create domain requests
|
||||
cls._bulk_create_requests(domain_requests_to_create)
|
||||
|
||||
|
|
|
@ -60,7 +60,10 @@ class UserPortfolioPermissionFixture:
|
|||
user=user,
|
||||
portfolio=portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
],
|
||||
)
|
||||
user_portfolio_permissions_to_create.append(user_portfolio_permission)
|
||||
else:
|
||||
|
|
|
@ -151,6 +151,27 @@ class UserFixture:
|
|||
"email": "skey@truss.works",
|
||||
"title": "Designer",
|
||||
},
|
||||
{
|
||||
"username": "f20b7a53-f40d-48f8-8c12-f42f35eede92",
|
||||
"first_name": "Kimberly",
|
||||
"last_name": "Aralar",
|
||||
"email": "kimberly.aralar@gsa.gov",
|
||||
"title": "Designer",
|
||||
},
|
||||
{
|
||||
"username": "4aa78480-6272-42f9-ac29-a034ebdd9231",
|
||||
"first_name": "Kaitlin",
|
||||
"last_name": "Abbitt",
|
||||
"email": "kaitlin.abbitt@cisa.dhs.gov",
|
||||
"title": "Product Manager",
|
||||
},
|
||||
{
|
||||
"username": "5e54fd98-6c11-4cb3-82b6-93ed8be50a61",
|
||||
"first_name": "Gina",
|
||||
"last_name": "Summers",
|
||||
"email": "gina.summers@ecstech.com",
|
||||
"title": "Scrum Master",
|
||||
},
|
||||
]
|
||||
|
||||
STAFF = [
|
||||
|
@ -175,6 +196,7 @@ class UserFixture:
|
|||
"username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd",
|
||||
"first_name": "Alysia-Analyst",
|
||||
"last_name": "Alysia-Analyst",
|
||||
"email": "abroddrick+1@truss.works",
|
||||
},
|
||||
{
|
||||
"username": "91a9b97c-bd0a-458d-9823-babfde7ebf44",
|
||||
|
@ -257,6 +279,18 @@ class UserFixture:
|
|||
"last_name": "Key-Analyst",
|
||||
"email": "skey+1@truss.works",
|
||||
},
|
||||
{
|
||||
"username": "cf2b32fe-280d-4bc0-96c2-99eec09ba4da",
|
||||
"first_name": "Kimberly-Analyst",
|
||||
"last_name": "Aralar-Analyst",
|
||||
"email": "kimberly.aralar+1@gsa.gov",
|
||||
},
|
||||
{
|
||||
"username": "80db923e-ac64-4128-9b6f-e54b2174a09b",
|
||||
"first_name": "Kaitlin-Analyst",
|
||||
"last_name": "Abbitt-Analyst",
|
||||
"email": "kaitlin.abbitt@gwe.cisa.dhs.gov",
|
||||
},
|
||||
]
|
||||
|
||||
# Additional emails to add to the AllowedEmail whitelist.
|
||||
|
@ -318,32 +352,65 @@ class UserFixture:
|
|||
|
||||
@staticmethod
|
||||
def _get_existing_users(users):
|
||||
# if users match existing users in db by email address, update the users with the username
|
||||
# from the db. this will prevent duplicate users (with same email) from being added to db.
|
||||
# it is ok to keep the old username in the db because the username will be updated by oidc process during login
|
||||
|
||||
# Extract email addresses from users
|
||||
emails = [user.get("email") for user in users]
|
||||
|
||||
# Fetch existing users by email
|
||||
existing_users_by_email = User.objects.filter(email__in=emails).values_list("email", "username", "id")
|
||||
|
||||
# Create a dictionary to map emails to existing usernames
|
||||
email_to_existing_user = {user[0]: user[1] for user in existing_users_by_email}
|
||||
|
||||
# Update the users list with the usernames from existing users by email
|
||||
for user in users:
|
||||
email = user.get("email")
|
||||
if email and email in email_to_existing_user:
|
||||
user["username"] = email_to_existing_user[email] # Update username with the existing one
|
||||
|
||||
# Get the user identifiers (username, id) for the existing users to query the database
|
||||
user_identifiers = [(user.get("username"), user.get("id")) for user in users]
|
||||
|
||||
# Fetch existing users by username or id
|
||||
existing_users = User.objects.filter(
|
||||
username__in=[user[0] for user in user_identifiers] + [user[1] for user in user_identifiers]
|
||||
).values_list("username", "id")
|
||||
|
||||
# Create sets for usernames and ids that exist
|
||||
existing_usernames = set(user[0] for user in existing_users)
|
||||
existing_user_ids = set(user[1] for user in existing_users)
|
||||
|
||||
return existing_usernames, existing_user_ids
|
||||
|
||||
@staticmethod
|
||||
def _prepare_new_users(users, existing_usernames, existing_user_ids, are_superusers):
|
||||
return [
|
||||
User(
|
||||
id=user_data.get("id"),
|
||||
first_name=user_data.get("first_name"),
|
||||
last_name=user_data.get("last_name"),
|
||||
username=user_data.get("username"),
|
||||
email=user_data.get("email", ""),
|
||||
new_users = []
|
||||
for i, user_data in enumerate(users):
|
||||
username = user_data.get("username")
|
||||
id = user_data.get("id")
|
||||
first_name = user_data.get("first_name", "Bob")
|
||||
last_name = user_data.get("last_name", "Builder")
|
||||
|
||||
default_email = f"placeholder.{first_name.lower()}.{last_name.lower()}+{i}@igorville.gov"
|
||||
email = user_data.get("email", default_email)
|
||||
if username not in existing_usernames and id not in existing_user_ids:
|
||||
user = User(
|
||||
id=id,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
username=username,
|
||||
email=email,
|
||||
title=user_data.get("title", "Peon"),
|
||||
phone=user_data.get("phone", "2022222222"),
|
||||
is_active=user_data.get("is_active", True),
|
||||
is_staff=True,
|
||||
is_superuser=are_superusers,
|
||||
)
|
||||
for user_data in users
|
||||
if user_data.get("username") not in existing_usernames and user_data.get("id") not in existing_user_ids
|
||||
]
|
||||
new_users.append(user)
|
||||
return new_users
|
||||
|
||||
@staticmethod
|
||||
def _create_new_users(new_users):
|
||||
|
|
|
@ -10,6 +10,7 @@ from .domain import (
|
|||
DomainDsdataFormset,
|
||||
DomainDsdataForm,
|
||||
DomainSuborganizationForm,
|
||||
DomainRenewalForm,
|
||||
)
|
||||
from .portfolio import (
|
||||
PortfolioOrgAddressForm,
|
||||
|
|
|
@ -4,6 +4,7 @@ import logging
|
|||
from django import forms
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
|
||||
from django.forms import formset_factory
|
||||
from registrar.forms.utility.combobox import ComboboxWidget
|
||||
from registrar.models import DomainRequest, FederalAgency
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
from registrar.models.suborganization import Suborganization
|
||||
|
@ -161,9 +162,10 @@ class DomainSuborganizationForm(forms.ModelForm):
|
|||
"""Form for updating the suborganization"""
|
||||
|
||||
sub_organization = forms.ModelChoiceField(
|
||||
label="Suborganization name",
|
||||
queryset=Suborganization.objects.none(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
widget=ComboboxWidget,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -178,20 +180,6 @@ class DomainSuborganizationForm(forms.ModelForm):
|
|||
portfolio = self.instance.portfolio if self.instance else None
|
||||
self.fields["sub_organization"].queryset = Suborganization.objects.filter(portfolio=portfolio)
|
||||
|
||||
# Set initial value
|
||||
if self.instance and self.instance.sub_organization:
|
||||
self.fields["sub_organization"].initial = self.instance.sub_organization
|
||||
|
||||
# Set custom form label
|
||||
self.fields["sub_organization"].label = "Suborganization name"
|
||||
|
||||
# Use the combobox rather than the regular select widget
|
||||
self.fields["sub_organization"].widget.template_name = "django/forms/widgets/combobox.html"
|
||||
|
||||
# Set data-default-value attribute
|
||||
if self.instance and self.instance.sub_organization:
|
||||
self.fields["sub_organization"].widget.attrs["data-default-value"] = self.instance.sub_organization.pk
|
||||
|
||||
|
||||
class BaseNameserverFormset(forms.BaseFormSet):
|
||||
def clean(self):
|
||||
|
@ -456,6 +444,13 @@ class DomainSecurityEmailForm(forms.Form):
|
|||
class DomainOrgNameAddressForm(forms.ModelForm):
|
||||
"""Form for updating the organization name and mailing address."""
|
||||
|
||||
# for federal agencies we also want to know the top-level agency.
|
||||
federal_agency = forms.ModelChoiceField(
|
||||
label="Federal agency",
|
||||
required=False,
|
||||
queryset=FederalAgency.objects.all(),
|
||||
widget=ComboboxWidget,
|
||||
)
|
||||
zipcode = forms.CharField(
|
||||
label="Zip code",
|
||||
validators=[
|
||||
|
@ -469,6 +464,16 @@ class DomainOrgNameAddressForm(forms.ModelForm):
|
|||
},
|
||||
)
|
||||
|
||||
state_territory = forms.ChoiceField(
|
||||
label="State, territory, or military post",
|
||||
required=True,
|
||||
choices=DomainInformation.StateTerritoryChoices.choices,
|
||||
error_messages={
|
||||
"required": ("Select the state, territory, or military post where your organization is located.")
|
||||
},
|
||||
widget=ComboboxWidget(attrs={"required": True}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DomainInformation
|
||||
fields = [
|
||||
|
@ -486,25 +491,12 @@ class DomainOrgNameAddressForm(forms.ModelForm):
|
|||
"organization_name": {"required": "Enter the name of your organization."},
|
||||
"address_line1": {"required": "Enter the street address of your organization."},
|
||||
"city": {"required": "Enter the city where your organization is located."},
|
||||
"state_territory": {
|
||||
"required": "Select the state, territory, or military post where your organization is located."
|
||||
},
|
||||
}
|
||||
widgets = {
|
||||
# We need to set the required attributed for State/territory
|
||||
# because for this fields we are creating an individual
|
||||
# instance of the Select. For the other fields we use the for loop to set
|
||||
# the class's required attribute to true.
|
||||
"organization_name": forms.TextInput,
|
||||
"address_line1": forms.TextInput,
|
||||
"address_line2": forms.TextInput,
|
||||
"city": forms.TextInput,
|
||||
"state_territory": forms.Select(
|
||||
attrs={
|
||||
"required": True,
|
||||
},
|
||||
choices=DomainInformation.StateTerritoryChoices.choices,
|
||||
),
|
||||
"urbanization": forms.TextInput,
|
||||
}
|
||||
|
||||
|
@ -661,3 +653,15 @@ DomainDsdataFormset = formset_factory(
|
|||
extra=0,
|
||||
can_delete=True,
|
||||
)
|
||||
|
||||
|
||||
class DomainRenewalForm(forms.Form):
|
||||
"""Form making sure domain renewal ack is checked"""
|
||||
|
||||
is_policy_acknowledged = forms.BooleanField(
|
||||
required=True,
|
||||
label="I have read and agree to the requirements for operating a .gov domain.",
|
||||
error_messages={
|
||||
"required": "Check the box if you read and agree to the requirements for operating a .gov domain."
|
||||
},
|
||||
)
|
||||
|
|
|
@ -7,6 +7,7 @@ from django import forms
|
|||
from django.core.validators import RegexValidator, MaxLengthValidator
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from registrar.forms.utility.combobox import ComboboxWidget
|
||||
from registrar.forms.utility.wizard_form_helper import (
|
||||
RegistrarForm,
|
||||
RegistrarFormSet,
|
||||
|
@ -17,6 +18,7 @@ from registrar.models import Contact, DomainRequest, DraftDomain, Domain, Federa
|
|||
from registrar.templatetags.url_helpers import public_site_url
|
||||
from registrar.utility.enums import ValidationReturnType
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -42,7 +44,7 @@ class RequestingEntityForm(RegistrarForm):
|
|||
label="Suborganization name",
|
||||
required=False,
|
||||
queryset=Suborganization.objects.none(),
|
||||
empty_label="--Select--",
|
||||
widget=ComboboxWidget,
|
||||
)
|
||||
requested_suborganization = forms.CharField(
|
||||
label="Requested suborganization",
|
||||
|
@ -55,22 +57,44 @@ class RequestingEntityForm(RegistrarForm):
|
|||
suborganization_state_territory = forms.ChoiceField(
|
||||
label="State, territory, or military post",
|
||||
required=False,
|
||||
choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices,
|
||||
choices=DomainRequest.StateTerritoryChoices.choices,
|
||||
widget=ComboboxWidget,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Override of init to add the suborganization queryset"""
|
||||
"""Override of init to add the suborganization queryset and 'other' option"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.domain_request.portfolio:
|
||||
self.fields["sub_organization"].queryset = Suborganization.objects.filter(
|
||||
portfolio=self.domain_request.portfolio
|
||||
)
|
||||
# Fetch the queryset for the portfolio
|
||||
queryset = Suborganization.objects.filter(portfolio=self.domain_request.portfolio)
|
||||
# set the queryset appropriately so that post can validate against queryset
|
||||
self.fields["sub_organization"].queryset = queryset
|
||||
|
||||
# Modify the choices to include "other" so that form can display options properly
|
||||
self.fields["sub_organization"].choices = [(obj.id, str(obj)) for obj in queryset] + [
|
||||
("other", "Other (enter your suborganization manually)")
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def from_database(cls, obj: DomainRequest | Contact | None):
|
||||
"""Returns a dict of form field values gotten from `obj`.
|
||||
Overrides RegistrarForm method in order to set sub_organization to 'other'
|
||||
on GETs of the RequestingEntityForm."""
|
||||
if obj is None:
|
||||
return {}
|
||||
# get the domain request as a dict, per usual method
|
||||
domain_request_dict = {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore
|
||||
|
||||
# set sub_organization to 'other' if is_requesting_new_suborganization is True
|
||||
if isinstance(obj, DomainRequest) and obj.is_requesting_new_suborganization():
|
||||
domain_request_dict["sub_organization"] = "other"
|
||||
|
||||
return domain_request_dict
|
||||
|
||||
def clean_sub_organization(self):
|
||||
"""On suborganization clean, set the suborganization value to None if the user is requesting
|
||||
a custom suborganization (as it doesn't exist yet)"""
|
||||
|
||||
# If it's a new suborganization, return None (equivalent to selecting nothing)
|
||||
if self.cleaned_data.get("is_requesting_new_suborganization"):
|
||||
return None
|
||||
|
@ -78,43 +102,76 @@ class RequestingEntityForm(RegistrarForm):
|
|||
# Otherwise just return the suborg as normal
|
||||
return self.cleaned_data.get("sub_organization")
|
||||
|
||||
def full_clean(self):
|
||||
"""Validation logic to remove the custom suborganization value before clean is triggered.
|
||||
Without this override, the form will throw an 'invalid option' error."""
|
||||
# Remove the custom other field before cleaning
|
||||
data = self.data.copy() if self.data else None
|
||||
def clean_requested_suborganization(self):
|
||||
name = self.cleaned_data.get("requested_suborganization")
|
||||
if (
|
||||
name
|
||||
and Suborganization.objects.filter(
|
||||
name__iexact=name, portfolio=self.domain_request.portfolio, name__isnull=False, portfolio__isnull=False
|
||||
).exists()
|
||||
):
|
||||
raise ValidationError(
|
||||
"This suborganization already exists. "
|
||||
"Choose a new name, or select it directly if you would like to use it."
|
||||
)
|
||||
return name
|
||||
|
||||
# Remove the 'other' value from suborganization if it exists.
|
||||
# This is a special value that tracks if the user is requesting a new suborg.
|
||||
suborganization = self.data.get("portfolio_requesting_entity-sub_organization")
|
||||
if suborganization and "other" in suborganization:
|
||||
def full_clean(self):
|
||||
"""Validation logic to temporarily remove the custom suborganization value before clean is triggered.
|
||||
Without this override, the form will throw an 'invalid option' error."""
|
||||
# Ensure self.data is not None before proceeding
|
||||
if self.data:
|
||||
# handle case where form has been submitted
|
||||
# Create a copy of the data for manipulation
|
||||
data = self.data.copy()
|
||||
|
||||
# Retrieve sub_organization and store in _original_suborganization
|
||||
suborganization = data.get("portfolio_requesting_entity-sub_organization")
|
||||
self._original_suborganization = suborganization
|
||||
# If the original value was "other", clear it for validation
|
||||
if self._original_suborganization == "other":
|
||||
data["portfolio_requesting_entity-sub_organization"] = ""
|
||||
|
||||
# Set the modified data back to the form
|
||||
self.data = data
|
||||
else:
|
||||
# handle case of a GET
|
||||
suborganization = None
|
||||
if self.initial and "sub_organization" in self.initial:
|
||||
suborganization = self.initial["sub_organization"]
|
||||
|
||||
# Check if is_requesting_new_suborganization is True
|
||||
is_requesting_new_suborganization = False
|
||||
if self.initial and "is_requesting_new_suborganization" in self.initial:
|
||||
# Call the method if it exists
|
||||
is_requesting_new_suborganization = self.initial["is_requesting_new_suborganization"]()
|
||||
|
||||
# Determine if "other" should be set
|
||||
if is_requesting_new_suborganization and suborganization is None:
|
||||
self._original_suborganization = "other"
|
||||
else:
|
||||
self._original_suborganization = suborganization
|
||||
|
||||
# Call the parent's full_clean method
|
||||
super().full_clean()
|
||||
|
||||
# Restore "other" if there are errors
|
||||
if self.errors:
|
||||
self.data["portfolio_requesting_entity-sub_organization"] = self._original_suborganization
|
||||
|
||||
def clean(self):
|
||||
"""Custom clean implementation to handle our desired logic flow for suborganization.
|
||||
Given that these fields often rely on eachother, we need to do this in the parent function."""
|
||||
"""Custom clean implementation to handle our desired logic flow for suborganization."""
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Do some custom error validation if the requesting entity is a suborg.
|
||||
# Otherwise, just validate as normal.
|
||||
suborganization = self.cleaned_data.get("sub_organization")
|
||||
is_requesting_new_suborganization = self.cleaned_data.get("is_requesting_new_suborganization")
|
||||
|
||||
# Get the value of the yes/no checkbox from RequestingEntityYesNoForm.
|
||||
# Since self.data stores this as a string, we need to convert "True" => True.
|
||||
# Get the cleaned data
|
||||
suborganization = cleaned_data.get("sub_organization")
|
||||
is_requesting_new_suborganization = cleaned_data.get("is_requesting_new_suborganization")
|
||||
requesting_entity_is_suborganization = self.data.get(
|
||||
"portfolio_requesting_entity-requesting_entity_is_suborganization"
|
||||
)
|
||||
if requesting_entity_is_suborganization == "True":
|
||||
if is_requesting_new_suborganization:
|
||||
# Validate custom suborganization fields
|
||||
if not cleaned_data.get("requested_suborganization"):
|
||||
if not cleaned_data.get("requested_suborganization") and "requested_suborganization" not in self.errors:
|
||||
self.add_error("requested_suborganization", "Enter the name of your suborganization.")
|
||||
if not cleaned_data.get("suborganization_city"):
|
||||
self.add_error("suborganization_city", "Enter the city where your suborganization is located.")
|
||||
|
@ -126,6 +183,12 @@ class RequestingEntityForm(RegistrarForm):
|
|||
elif not suborganization:
|
||||
self.add_error("sub_organization", "Suborganization is required.")
|
||||
|
||||
# If there are errors, restore the "other" value for rendering
|
||||
if self.errors and getattr(self, "_original_suborganization", None) == "other":
|
||||
self.cleaned_data["sub_organization"] = self._original_suborganization
|
||||
elif not self.data and getattr(self, "_original_suborganization", None) == "other":
|
||||
self.cleaned_data["sub_organization"] = self._original_suborganization
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
|
@ -144,9 +207,12 @@ class RequestingEntityYesNoForm(BaseYesNoForm):
|
|||
"""Extend the initialization of the form from RegistrarForm __init__"""
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.domain_request.portfolio:
|
||||
choose_text = (
|
||||
"(choose from list)" if self.domain_request.portfolio.portfolio_suborganizations.exists() else ""
|
||||
)
|
||||
self.form_choices = (
|
||||
(False, self.domain_request.portfolio),
|
||||
(True, "A suborganization (choose from list)"),
|
||||
(True, f"A suborganization {choose_text}"),
|
||||
)
|
||||
self.fields[self.field_name] = self.get_typed_choice_field()
|
||||
|
||||
|
@ -256,7 +322,7 @@ class OrganizationContactForm(RegistrarForm):
|
|||
# uncomment to see if modelChoiceField can be an arg later
|
||||
required=False,
|
||||
queryset=FederalAgency.objects.exclude(agency__in=excluded_agencies),
|
||||
empty_label="--Select--",
|
||||
widget=ComboboxWidget,
|
||||
)
|
||||
organization_name = forms.CharField(
|
||||
label="Organization name",
|
||||
|
@ -276,10 +342,11 @@ class OrganizationContactForm(RegistrarForm):
|
|||
)
|
||||
state_territory = forms.ChoiceField(
|
||||
label="State, territory, or military post",
|
||||
choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices,
|
||||
choices=DomainRequest.StateTerritoryChoices.choices,
|
||||
error_messages={
|
||||
"required": ("Select the state, territory, or military post where your organization is located.")
|
||||
},
|
||||
widget=ComboboxWidget,
|
||||
)
|
||||
zipcode = forms.CharField(
|
||||
label="Zip code",
|
||||
|
@ -395,6 +462,7 @@ class CurrentSitesForm(RegistrarForm):
|
|||
error_messages={
|
||||
"invalid": ("Enter your organization's current website in the required format, like example.com.")
|
||||
},
|
||||
widget=forms.URLInput(attrs={"aria-labelledby": "id_current_sites_header id_current_sites_body"}),
|
||||
)
|
||||
|
||||
|
||||
|
@ -530,7 +598,7 @@ class PurposeForm(RegistrarForm):
|
|||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"aria-label": "What is the purpose of your requested domain? Describe how you’ll use your .gov domain. \
|
||||
Will it be used for a website, email, or something else? You can enter up to 2000 characters."
|
||||
Will it be used for a website, email, or something else?"
|
||||
}
|
||||
),
|
||||
validators=[
|
||||
|
@ -736,7 +804,13 @@ class NoOtherContactsForm(BaseDeletableRegistrarForm):
|
|||
required=True,
|
||||
# label has to end in a space to get the label_suffix to show
|
||||
label=("No other employees rationale"),
|
||||
widget=forms.Textarea(),
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"aria-label": "You don’t need to provide names of other employees now, \
|
||||
but it may slow down our assessment of your eligibility. Describe \
|
||||
why there are no other employees who can help verify your request."
|
||||
}
|
||||
),
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
1000,
|
||||
|
@ -784,7 +858,12 @@ class AnythingElseForm(BaseDeletableRegistrarForm):
|
|||
anything_else = forms.CharField(
|
||||
required=True,
|
||||
label="Anything else?",
|
||||
widget=forms.Textarea(),
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"aria-label": "Is there anything else you’d like us to know about your domain request? \
|
||||
Provide details below. You can enter up to 2000 characters"
|
||||
}
|
||||
),
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
2000,
|
||||
|
|
|
@ -5,13 +5,13 @@ from django import forms
|
|||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import MaxLengthValidator
|
||||
|
||||
from registrar.forms.utility.combobox import ComboboxWidget
|
||||
from registrar.models import (
|
||||
PortfolioInvitation,
|
||||
UserPortfolioPermission,
|
||||
DomainInformation,
|
||||
Portfolio,
|
||||
SeniorOfficial,
|
||||
User,
|
||||
)
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
||||
|
@ -33,6 +33,15 @@ class PortfolioOrgAddressForm(forms.ModelForm):
|
|||
"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
},
|
||||
)
|
||||
state_territory = forms.ChoiceField(
|
||||
label="State, territory, or military post",
|
||||
required=True,
|
||||
choices=DomainInformation.StateTerritoryChoices.choices,
|
||||
error_messages={
|
||||
"required": ("Select the state, territory, or military post where your organization is located.")
|
||||
},
|
||||
widget=ComboboxWidget(attrs={"required": True}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Portfolio
|
||||
|
@ -47,25 +56,12 @@ class PortfolioOrgAddressForm(forms.ModelForm):
|
|||
error_messages = {
|
||||
"address_line1": {"required": "Enter the street address of your organization."},
|
||||
"city": {"required": "Enter the city where your organization is located."},
|
||||
"state_territory": {
|
||||
"required": "Select the state, territory, or military post where your organization is located."
|
||||
},
|
||||
"zipcode": {"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."},
|
||||
}
|
||||
widgets = {
|
||||
# We need to set the required attributed for State/territory
|
||||
# because for this fields we are creating an individual
|
||||
# instance of the Select. For the other fields we use the for loop to set
|
||||
# the class's required attribute to true.
|
||||
"address_line1": forms.TextInput,
|
||||
"address_line2": forms.TextInput,
|
||||
"city": forms.TextInput,
|
||||
"state_territory": forms.Select(
|
||||
attrs={
|
||||
"required": True,
|
||||
},
|
||||
choices=DomainInformation.StateTerritoryChoices.choices,
|
||||
),
|
||||
# "urbanization": forms.TextInput,
|
||||
}
|
||||
|
||||
|
@ -110,104 +106,240 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
|
|||
return cleaned_data
|
||||
|
||||
|
||||
class PortfolioMemberForm(forms.ModelForm):
|
||||
"""
|
||||
Form for updating a portfolio member.
|
||||
"""
|
||||
class BasePortfolioMemberForm(forms.ModelForm):
|
||||
"""Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm"""
|
||||
|
||||
roles = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Roles",
|
||||
)
|
||||
|
||||
additional_permissions = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Additional Permissions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserPortfolioPermission
|
||||
fields = [
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
]
|
||||
|
||||
|
||||
class PortfolioInvitedMemberForm(forms.ModelForm):
|
||||
"""
|
||||
Form for updating a portfolio invited member.
|
||||
"""
|
||||
|
||||
roles = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Roles",
|
||||
)
|
||||
|
||||
additional_permissions = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Additional Permissions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PortfolioInvitation
|
||||
fields = [
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
]
|
||||
|
||||
|
||||
class NewMemberForm(forms.ModelForm):
|
||||
member_access_level = forms.ChoiceField(
|
||||
label="Select permission",
|
||||
choices=[("admin", "Admin Access"), ("basic", "Basic Access")],
|
||||
widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}),
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Member access level is required",
|
||||
},
|
||||
)
|
||||
admin_org_domain_request_permissions = forms.ChoiceField(
|
||||
label="Select permission",
|
||||
choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")],
|
||||
widget=forms.RadioSelect,
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Admin domain request permission is required",
|
||||
},
|
||||
)
|
||||
admin_org_members_permissions = forms.ChoiceField(
|
||||
label="Select permission",
|
||||
choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")],
|
||||
widget=forms.RadioSelect,
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Admin member permission is required",
|
||||
},
|
||||
)
|
||||
basic_org_domain_request_permissions = forms.ChoiceField(
|
||||
label="Select permission",
|
||||
# The label for each of these has a red "required" star. We can just embed that here for simplicity.
|
||||
required_star = '<abbr class="usa-hint usa-hint--required" title="required">*</abbr>'
|
||||
role = forms.ChoiceField(
|
||||
choices=[
|
||||
("view_only", "View all requests"),
|
||||
("view_and_create", "View all requests plus create requests"),
|
||||
("no_access", "No access"),
|
||||
# Uses .value because the choice has a different label (on /admin)
|
||||
(UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"),
|
||||
(UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access"),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Basic member permission is required",
|
||||
"required": "Select the level of access you would like to grant this member.",
|
||||
},
|
||||
)
|
||||
|
||||
domain_permissions = forms.ChoiceField(
|
||||
choices=[
|
||||
(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"),
|
||||
(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
required=False,
|
||||
initial=UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
|
||||
error_messages={
|
||||
"required": "Domain permission is required.",
|
||||
},
|
||||
)
|
||||
|
||||
domain_request_permissions = forms.ChoiceField(
|
||||
choices=[
|
||||
("no_access", "No access"),
|
||||
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "Viewer"),
|
||||
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Creator"),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
required=False,
|
||||
initial="no_access",
|
||||
error_messages={
|
||||
"required": "Domain request permission is required.",
|
||||
},
|
||||
)
|
||||
|
||||
member_permissions = forms.ChoiceField(
|
||||
choices=[
|
||||
("no_access", "No access"),
|
||||
(UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "Viewer"),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
required=False,
|
||||
initial="no_access",
|
||||
error_messages={
|
||||
"required": "Member permission is required.",
|
||||
},
|
||||
)
|
||||
|
||||
# Tracks what form elements are required for a given role choice.
|
||||
# All of the fields included here have "required=False" by default as they are conditionally required.
|
||||
# see def clean() for more details.
|
||||
ROLE_REQUIRED_FIELDS = {
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [],
|
||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
||||
"domain_permissions",
|
||||
"member_permissions",
|
||||
"domain_request_permissions",
|
||||
],
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = None
|
||||
fields = ["roles", "additional_permissions"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Override the form's initialization.
|
||||
|
||||
Map existing model values to custom form fields.
|
||||
Update field descriptions.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# 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",
|
||||
}
|
||||
self.fields["domain_request_permissions"].descriptions = {
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS.value: (
|
||||
"Can view all domain requests for the organization and create requests"
|
||||
),
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: "Can view all domain requests for the organization",
|
||||
"no_access": "Cannot view or create domain requests",
|
||||
}
|
||||
self.fields["member_permissions"].descriptions = {
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS.value: "Can view all members permissions",
|
||||
"no_access": "Cannot view member permissions",
|
||||
}
|
||||
|
||||
# Map model instance values to custom form fields
|
||||
if self.instance:
|
||||
self.map_instance_to_initial()
|
||||
|
||||
def clean(self):
|
||||
"""Validates form data based on selected role and its required fields.
|
||||
Updates roles and additional_permissions in cleaned_data so they can be properly
|
||||
mapped to the model.
|
||||
"""
|
||||
cleaned_data = super().clean()
|
||||
role = cleaned_data.get("role")
|
||||
|
||||
# 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:
|
||||
# Helpful error for if this breaks
|
||||
if field_name not in self.fields:
|
||||
raise ValueError(f"ROLE_REQUIRED_FIELDS referenced a non-existent field: {field_name}.")
|
||||
|
||||
if not cleaned_data.get(field_name):
|
||||
self.add_error(field_name, self.fields.get(field_name).error_messages.get("required"))
|
||||
|
||||
# Edgecase: Member uses a special form value for None called "no_access".
|
||||
if cleaned_data.get("domain_request_permissions") == "no_access":
|
||||
cleaned_data["domain_request_permissions"] = None
|
||||
|
||||
# Edgecase: Member uses a special form value for None called "no_access".
|
||||
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)}
|
||||
|
||||
# Handle EDIT permissions (should be accompanied with a view permission)
|
||||
if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions:
|
||||
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS)
|
||||
|
||||
if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions:
|
||||
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
|
||||
|
||||
# Only set unique permissions not already defined in the base role
|
||||
role_permissions = UserPortfolioPermission.get_portfolio_permissions(cleaned_data["roles"], [], get_list=False)
|
||||
cleaned_data["additional_permissions"] = list(additional_permissions - role_permissions)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def map_instance_to_initial(self):
|
||||
"""
|
||||
Maps self.instance to self.initial, handling roles and permissions.
|
||||
Updates self.initial dictionary with appropriate permission levels based on user role:
|
||||
{
|
||||
"role": "organization_admin" or "organization_member",
|
||||
"member_permission_admin": permission level if admin,
|
||||
"domain_request_permission_admin": permission level if admin,
|
||||
"domain_request_permissions": permission level if member
|
||||
}
|
||||
"""
|
||||
if self.initial is None:
|
||||
self.initial = {}
|
||||
# Function variables
|
||||
perms = UserPortfolioPermission.get_portfolio_permissions(
|
||||
self.instance.roles, self.instance.additional_permissions, get_list=False
|
||||
)
|
||||
# Get the available options for roles, domains, and member.
|
||||
roles = [
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
|
||||
]
|
||||
domain_request_perms = [
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
]
|
||||
domain_perms = [
|
||||
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
]
|
||||
member_perms = [
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
]
|
||||
|
||||
# Build form data based on role (which options are available).
|
||||
# Get which one should be "selected" by assuming that EDIT takes precedence over view,
|
||||
# and ADMIN takes precedence over MEMBER.
|
||||
roles = self.instance.roles or []
|
||||
selected_role = next((role for role in roles if role in roles), None)
|
||||
self.initial["role"] = selected_role
|
||||
is_member = selected_role == UserPortfolioRoleChoices.ORGANIZATION_MEMBER
|
||||
if is_member:
|
||||
# Edgecase: Member and domain request use a special form value for None called "no_access".
|
||||
# This ensures a form selection.
|
||||
selected_domain_permission = next(
|
||||
(perm for perm in domain_perms if perm in perms),
|
||||
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
|
||||
)
|
||||
selected_domain_request_permission = next(
|
||||
(perm for perm in domain_request_perms if perm in perms), "no_access"
|
||||
)
|
||||
selected_member_permission = next((perm for perm in member_perms if perm in perms), "no_access")
|
||||
self.initial["domain_request_permissions"] = selected_domain_request_permission
|
||||
self.initial["domain_permissions"] = selected_domain_permission
|
||||
self.initial["member_permissions"] = selected_member_permission
|
||||
|
||||
|
||||
class PortfolioMemberForm(BasePortfolioMemberForm):
|
||||
"""
|
||||
Form for updating a portfolio member.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = UserPortfolioPermission
|
||||
fields = ["roles", "additional_permissions"]
|
||||
|
||||
|
||||
class PortfolioInvitedMemberForm(BasePortfolioMemberForm):
|
||||
"""
|
||||
Form for updating a portfolio invited member.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = PortfolioInvitation
|
||||
fields = ["roles", "additional_permissions"]
|
||||
|
||||
|
||||
class PortfolioNewMemberForm(BasePortfolioMemberForm):
|
||||
"""
|
||||
Form for adding a portfolio invited member.
|
||||
"""
|
||||
|
||||
email = forms.EmailField(
|
||||
label="Enter the email of the member you'd like to invite",
|
||||
label="Email",
|
||||
max_length=None,
|
||||
error_messages={
|
||||
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||
|
@ -223,51 +355,5 @@ class NewMemberForm(forms.ModelForm):
|
|||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["email"]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Lowercase the value of the 'email' field
|
||||
email_value = cleaned_data.get("email")
|
||||
if email_value:
|
||||
cleaned_data["email"] = email_value.lower()
|
||||
|
||||
##########################################
|
||||
# TODO: future ticket
|
||||
# (invite new member)
|
||||
##########################################
|
||||
# Check for an existing user (if there isn't any, send an invite)
|
||||
# if email_value:
|
||||
# try:
|
||||
# existingUser = User.objects.get(email=email_value)
|
||||
# except User.DoesNotExist:
|
||||
# raise forms.ValidationError("User with this email does not exist.")
|
||||
|
||||
member_access_level = cleaned_data.get("member_access_level")
|
||||
|
||||
# Intercept the error messages so that we don't validate hidden inputs
|
||||
if not member_access_level:
|
||||
# If no member access level has been selected, delete error messages
|
||||
# for all hidden inputs (which is everything except the e-mail input
|
||||
# and member access selection)
|
||||
for field in self.fields:
|
||||
if field in self.errors and field != "email" and field != "member_access_level":
|
||||
del self.errors[field]
|
||||
return cleaned_data
|
||||
|
||||
basic_dom_req_error = "basic_org_domain_request_permissions"
|
||||
admin_dom_req_error = "admin_org_domain_request_permissions"
|
||||
admin_member_error = "admin_org_members_permissions"
|
||||
|
||||
if member_access_level == "admin" and basic_dom_req_error in self.errors:
|
||||
# remove the error messages pertaining to basic permission inputs
|
||||
del self.errors[basic_dom_req_error]
|
||||
elif member_access_level == "basic":
|
||||
# remove the error messages pertaining to admin permission inputs
|
||||
if admin_dom_req_error in self.errors:
|
||||
del self.errors[admin_dom_req_error]
|
||||
if admin_member_error in self.errors:
|
||||
del self.errors[admin_member_error]
|
||||
return cleaned_data
|
||||
model = PortfolioInvitation
|
||||
fields = ["portfolio", "email", "roles", "additional_permissions"]
|
||||
|
|
5
src/registrar/forms/utility/combobox.py
Normal file
5
src/registrar/forms/utility/combobox.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.forms import Select
|
||||
|
||||
|
||||
class ComboboxWidget(Select):
|
||||
template_name = "django/forms/widgets/combobox.html"
|
|
@ -5,6 +5,8 @@ import logging
|
|||
from django.core.management import BaseCommand, CommandError
|
||||
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
|
||||
from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User
|
||||
from registrar.models.utility.generic_helper import normalize_string
|
||||
from django.db.models import F, Q
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -21,10 +23,21 @@ class Command(BaseCommand):
|
|||
self.failed_portfolios = set()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add three arguments:
|
||||
1. agency_name => the value of FederalAgency.agency
|
||||
2. --parse_requests => if true, adds the given portfolio to each related DomainRequest
|
||||
3. --parse_domains => if true, adds the given portfolio to each related DomainInformation
|
||||
"""Add command line arguments to create federal portfolios.
|
||||
|
||||
Required (mutually exclusive) arguments:
|
||||
--agency_name: Name of a specific FederalAgency to create a portfolio for
|
||||
--branch: Federal branch to process ("executive", "legislative", or "judicial").
|
||||
Creates portfolios for all FederalAgencies in that branch.
|
||||
|
||||
Required (at least one):
|
||||
--parse_requests: Add the created portfolio(s) to related DomainRequest records
|
||||
--parse_domains: Add the created portfolio(s) to related DomainInformation records
|
||||
Note: You can use both --parse_requests and --parse_domains together
|
||||
|
||||
Optional (mutually exclusive with parse options):
|
||||
--both: Shorthand for using both --parse_requests and --parse_domains
|
||||
Cannot be used with --parse_requests or --parse_domains
|
||||
"""
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
|
@ -51,6 +64,11 @@ class Command(BaseCommand):
|
|||
action=argparse.BooleanOptionalAction,
|
||||
help="Adds portfolio to both requests and domains",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip_existing_portfolios",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Only add suborganizations to newly created portfolios, skip existing ones.",
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
agency_name = options.get("agency_name")
|
||||
|
@ -58,6 +76,7 @@ class Command(BaseCommand):
|
|||
parse_requests = options.get("parse_requests")
|
||||
parse_domains = options.get("parse_domains")
|
||||
both = options.get("both")
|
||||
skip_existing_portfolios = options.get("skip_existing_portfolios")
|
||||
|
||||
if not both:
|
||||
if not parse_requests and not parse_domains:
|
||||
|
@ -78,32 +97,115 @@ class Command(BaseCommand):
|
|||
else:
|
||||
raise CommandError(f"Cannot find '{branch}' federal agencies in our database.")
|
||||
|
||||
portfolios = []
|
||||
for federal_agency in agencies:
|
||||
message = f"Processing federal agency '{federal_agency.agency}'..."
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
|
||||
try:
|
||||
# C901 'Command.handle' is too complex (12)
|
||||
self.handle_populate_portfolio(federal_agency, parse_domains, parse_requests, both)
|
||||
portfolio = self.handle_populate_portfolio(
|
||||
federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios
|
||||
)
|
||||
portfolios.append(portfolio)
|
||||
except Exception as exec:
|
||||
self.failed_portfolios.add(federal_agency)
|
||||
logger.error(exec)
|
||||
message = f"Failed to create portfolio '{federal_agency.agency}'"
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.FAIL, message)
|
||||
|
||||
# POST PROCESS STEP: Add additional suborg info where applicable.
|
||||
updated_suborg_count = self.post_process_all_suborganization_fields(agencies)
|
||||
message = f"Added city and state_territory information to {updated_suborg_count} suborgs."
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
|
||||
TerminalHelper.log_script_run_summary(
|
||||
self.updated_portfolios,
|
||||
self.failed_portfolios,
|
||||
self.skipped_portfolios,
|
||||
debug=False,
|
||||
skipped_header="----- SOME PORTFOLIOS WERE SKIPPED -----",
|
||||
log_header="============= FINISHED HANDLE PORTFOLIO STEP ===============",
|
||||
skipped_header="----- SOME PORTFOLIOS WERENT CREATED (BUT OTHER RECORDS ARE STILL PROCESSED) -----",
|
||||
display_as_str=True,
|
||||
)
|
||||
|
||||
def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both):
|
||||
# POST PROCESSING STEP: Remove the federal agency if it matches the portfolio name.
|
||||
# We only do this for started domain requests.
|
||||
if parse_requests or both:
|
||||
prompt_message = (
|
||||
"This action will update domain requests even if they aren't on a portfolio."
|
||||
"\nNOTE: This will modify domain requests, even if no portfolios were created."
|
||||
"\nIn the event no portfolios *are* created, then this step will target "
|
||||
"the existing portfolios with your given params."
|
||||
"\nThis step is entirely optional, and is just for extra data cleanup."
|
||||
)
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
prompt_message=prompt_message,
|
||||
prompt_title=(
|
||||
"POST PROCESS STEP: Do you want to clear federal agency on (related) started domain requests?"
|
||||
),
|
||||
verify_message="*** THIS STEP IS OPTIONAL ***",
|
||||
)
|
||||
self.post_process_started_domain_requests(agencies, portfolios)
|
||||
|
||||
def post_process_started_domain_requests(self, agencies, portfolios):
|
||||
"""
|
||||
Removes duplicate organization data by clearing federal_agency when it matches the portfolio name.
|
||||
Only processes domain requests in STARTED status.
|
||||
"""
|
||||
message = "Removing duplicate portfolio and federal_agency values from domain requests..."
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
|
||||
|
||||
# For each request, clear the federal agency under these conditions:
|
||||
# 1. A portfolio *already exists* with the same name as the federal agency.
|
||||
# 2. Said portfolio (or portfolios) are only the ones specified at the start of the script.
|
||||
# 3. The domain request is in status "started".
|
||||
# Note: Both names are normalized so excess spaces are stripped and the string is lowercased.
|
||||
domain_requests_to_update = DomainRequest.objects.filter(
|
||||
federal_agency__in=agencies,
|
||||
federal_agency__agency__isnull=False,
|
||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||
organization_name__isnull=False,
|
||||
)
|
||||
|
||||
if domain_requests_to_update.count() == 0:
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, "No domain requests to update.")
|
||||
return
|
||||
|
||||
portfolio_set = {normalize_string(portfolio.organization_name) for portfolio in portfolios if portfolio}
|
||||
|
||||
# Update the request, assuming the given agency name matches the portfolio name
|
||||
updated_requests = []
|
||||
for req in domain_requests_to_update:
|
||||
agency_name = normalize_string(req.federal_agency.agency)
|
||||
if agency_name in portfolio_set:
|
||||
req.federal_agency = None
|
||||
updated_requests.append(req)
|
||||
|
||||
# Execute the update and Log the results
|
||||
if TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=False,
|
||||
prompt_message=(
|
||||
f"{len(domain_requests_to_update)} domain requests will be updated. "
|
||||
f"These records will be changed: {[str(req) for req in updated_requests]}"
|
||||
),
|
||||
prompt_title="Do you wish to commit this update to the database?",
|
||||
):
|
||||
DomainRequest.objects.bulk_update(updated_requests, ["federal_agency"])
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, "Action completed successfully.")
|
||||
|
||||
def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios):
|
||||
"""Attempts to create a portfolio. If successful, this function will
|
||||
also create new suborganizations"""
|
||||
portfolio, created = self.create_portfolio(federal_agency)
|
||||
if created:
|
||||
if skip_existing_portfolios and not created:
|
||||
TerminalHelper.colorful_logger(
|
||||
logger.warning,
|
||||
TerminalColors.YELLOW,
|
||||
"Skipping modifications to suborgs, domain requests, and "
|
||||
"domains due to the --skip_existing_portfolios flag. Portfolio already exists.",
|
||||
)
|
||||
return portfolio
|
||||
|
||||
self.create_suborganizations(portfolio, federal_agency)
|
||||
if parse_domains or both:
|
||||
self.handle_portfolio_domains(portfolio, federal_agency)
|
||||
|
@ -111,6 +213,8 @@ class Command(BaseCommand):
|
|||
if parse_requests or both:
|
||||
self.handle_portfolio_requests(portfolio, federal_agency)
|
||||
|
||||
return portfolio
|
||||
|
||||
def create_portfolio(self, federal_agency):
|
||||
"""Creates a portfolio if it doesn't presently exist.
|
||||
Returns portfolio, created."""
|
||||
|
@ -161,7 +265,6 @@ class Command(BaseCommand):
|
|||
federal_agency=federal_agency, organization_name__isnull=False
|
||||
)
|
||||
org_names = set(valid_agencies.values_list("organization_name", flat=True))
|
||||
|
||||
if not org_names:
|
||||
message = (
|
||||
"Could not add any suborganizations."
|
||||
|
@ -172,7 +275,7 @@ class Command(BaseCommand):
|
|||
return
|
||||
|
||||
# Check for existing suborgs on the current portfolio
|
||||
existing_suborgs = Suborganization.objects.filter(name__in=org_names)
|
||||
existing_suborgs = Suborganization.objects.filter(name__in=org_names, name__isnull=False)
|
||||
if existing_suborgs.exists():
|
||||
message = f"Some suborganizations already exist for portfolio '{portfolio}'."
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, message)
|
||||
|
@ -180,9 +283,7 @@ class Command(BaseCommand):
|
|||
# Create new suborgs, as long as they don't exist in the db already
|
||||
new_suborgs = []
|
||||
for name in org_names - set(existing_suborgs.values_list("name", flat=True)):
|
||||
# Stored in variables due to linter wanting type information here.
|
||||
portfolio_name: str = portfolio.organization_name if portfolio.organization_name is not None else ""
|
||||
if name is not None and name.lower() == portfolio_name.lower():
|
||||
if normalize_string(name) == normalize_string(portfolio.organization_name):
|
||||
# You can use this to populate location information, when this occurs.
|
||||
# However, this isn't needed for now so we can skip it.
|
||||
message = (
|
||||
|
@ -211,15 +312,13 @@ class Command(BaseCommand):
|
|||
DomainRequest.DomainRequestStatus.INELIGIBLE,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
]
|
||||
domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency, portfolio__isnull=True).exclude(
|
||||
status__in=invalid_states
|
||||
)
|
||||
domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency).exclude(status__in=invalid_states)
|
||||
if not domain_requests.exists():
|
||||
message = f"""
|
||||
Portfolio '{portfolio}' not added to domain requests: no valid records found.
|
||||
This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results.
|
||||
Excluded statuses: STARTED, INELIGIBLE, REJECTED.
|
||||
Filter info: DomainRequest.objects.filter(federal_agency=federal_agency, portfolio__isnull=True).exclude(
|
||||
Filter info: DomainRequest.objects.filter(federal_agency=federal_agency).exclude(
|
||||
status__in=invalid_states
|
||||
)
|
||||
"""
|
||||
|
@ -229,12 +328,30 @@ class Command(BaseCommand):
|
|||
# Get all suborg information and store it in a dict to avoid doing a db call
|
||||
suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name")
|
||||
for domain_request in domain_requests:
|
||||
# Set the portfolio
|
||||
domain_request.portfolio = portfolio
|
||||
if domain_request.organization_name in suborgs:
|
||||
domain_request.sub_organization = suborgs.get(domain_request.organization_name)
|
||||
|
||||
# Set suborg info
|
||||
domain_request.sub_organization = suborgs.get(domain_request.organization_name, None)
|
||||
if domain_request.sub_organization is None:
|
||||
domain_request.requested_suborganization = normalize_string(
|
||||
domain_request.organization_name, lowercase=False
|
||||
)
|
||||
domain_request.suborganization_city = normalize_string(domain_request.city, lowercase=False)
|
||||
domain_request.suborganization_state_territory = domain_request.state_territory
|
||||
|
||||
self.updated_portfolios.add(portfolio)
|
||||
|
||||
DomainRequest.objects.bulk_update(domain_requests, ["portfolio", "sub_organization"])
|
||||
DomainRequest.objects.bulk_update(
|
||||
domain_requests,
|
||||
[
|
||||
"portfolio",
|
||||
"sub_organization",
|
||||
"requested_suborganization",
|
||||
"suborganization_city",
|
||||
"suborganization_state_territory",
|
||||
],
|
||||
)
|
||||
message = f"Added portfolio '{portfolio}' to {len(domain_requests)} domain requests."
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
|
||||
|
||||
|
@ -242,13 +359,15 @@ class Command(BaseCommand):
|
|||
"""
|
||||
Associate portfolio with domains for a federal agency.
|
||||
Updates all relevant domain information records.
|
||||
|
||||
Returns a queryset of DomainInformation objects, or None if nothing changed.
|
||||
"""
|
||||
domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency, portfolio__isnull=True)
|
||||
domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency)
|
||||
if not domain_infos.exists():
|
||||
message = f"""
|
||||
Portfolio '{portfolio}' not added to domains: no valid records found.
|
||||
The filter on DomainInformation for the federal_agency '{federal_agency}' returned no results.
|
||||
Filter info: DomainInformation.objects.filter(federal_agency=federal_agency, portfolio__isnull=True)
|
||||
Filter info: DomainInformation.objects.filter(federal_agency=federal_agency)
|
||||
"""
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
|
||||
return None
|
||||
|
@ -257,9 +376,146 @@ class Command(BaseCommand):
|
|||
suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name")
|
||||
for domain_info in domain_infos:
|
||||
domain_info.portfolio = portfolio
|
||||
if domain_info.organization_name in suborgs:
|
||||
domain_info.sub_organization = suborgs.get(domain_info.organization_name)
|
||||
domain_info.sub_organization = suborgs.get(domain_info.organization_name, None)
|
||||
|
||||
DomainInformation.objects.bulk_update(domain_infos, ["portfolio", "sub_organization"])
|
||||
message = f"Added portfolio '{portfolio}' to {len(domain_infos)} domains."
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
|
||||
|
||||
def post_process_all_suborganization_fields(self, agencies):
|
||||
"""Batch updates suborganization locations from domain and request data.
|
||||
|
||||
Args:
|
||||
agencies: List of FederalAgency objects to process
|
||||
|
||||
Returns:
|
||||
int: Number of suborganizations updated
|
||||
|
||||
Priority for location data:
|
||||
1. Domain information
|
||||
2. Domain request suborganization fields
|
||||
3. Domain request standard fields
|
||||
"""
|
||||
# Common filter between domaininformation / domain request.
|
||||
# Filter by only the agencies we've updated thus far.
|
||||
# Then, only process records without null portfolio, org name, or suborg name.
|
||||
base_filter = Q(
|
||||
federal_agency__in=agencies,
|
||||
portfolio__isnull=False,
|
||||
organization_name__isnull=False,
|
||||
sub_organization__isnull=False,
|
||||
) & ~Q(organization_name__iexact=F("portfolio__organization_name"))
|
||||
|
||||
# First: Remove null city / state_territory values on domain info / domain requests.
|
||||
# We want to add city data if there is data to add to begin with!
|
||||
domains = DomainInformation.objects.filter(
|
||||
base_filter,
|
||||
Q(city__isnull=False, state_territory__isnull=False),
|
||||
)
|
||||
requests = DomainRequest.objects.filter(
|
||||
base_filter,
|
||||
(
|
||||
Q(city__isnull=False, state_territory__isnull=False)
|
||||
| Q(suborganization_city__isnull=False, suborganization_state_territory__isnull=False)
|
||||
),
|
||||
)
|
||||
|
||||
# Second: Group domains and requests by normalized organization name.
|
||||
# This means that later down the line we have to account for "duplicate" org names.
|
||||
domains_dict = {}
|
||||
requests_dict = {}
|
||||
for domain in domains:
|
||||
normalized_name = normalize_string(domain.organization_name)
|
||||
domains_dict.setdefault(normalized_name, []).append(domain)
|
||||
|
||||
for request in requests:
|
||||
normalized_name = normalize_string(request.organization_name)
|
||||
requests_dict.setdefault(normalized_name, []).append(request)
|
||||
|
||||
# Third: Get suborganizations to update
|
||||
suborgs_to_edit = Suborganization.objects.filter(
|
||||
Q(id__in=domains.values_list("sub_organization", flat=True))
|
||||
| Q(id__in=requests.values_list("sub_organization", flat=True))
|
||||
)
|
||||
|
||||
# Fourth: Process each suborg to add city / state territory info
|
||||
for suborg in suborgs_to_edit:
|
||||
self.post_process_suborganization_fields(suborg, domains_dict, requests_dict)
|
||||
|
||||
# Fifth: Perform a bulk update
|
||||
return Suborganization.objects.bulk_update(suborgs_to_edit, ["city", "state_territory"])
|
||||
|
||||
def post_process_suborganization_fields(self, suborg, domains_dict, requests_dict):
|
||||
"""Updates a single suborganization's location data if valid.
|
||||
|
||||
Args:
|
||||
suborg: Suborganization to update
|
||||
domains_dict: Dict of domain info records grouped by org name
|
||||
requests_dict: Dict of domain requests grouped by org name
|
||||
|
||||
Priority matches parent method. Updates are skipped if location data conflicts
|
||||
between multiple records of the same type.
|
||||
"""
|
||||
normalized_suborg_name = normalize_string(suborg.name)
|
||||
domains = domains_dict.get(normalized_suborg_name, [])
|
||||
requests = requests_dict.get(normalized_suborg_name, [])
|
||||
|
||||
# Try to get matching domain info
|
||||
domain = None
|
||||
if domains:
|
||||
reference = domains[0]
|
||||
use_location_for_domain = all(
|
||||
d.city == reference.city and d.state_territory == reference.state_territory for d in domains
|
||||
)
|
||||
if use_location_for_domain:
|
||||
domain = reference
|
||||
|
||||
# Try to get matching request info
|
||||
# Uses consensus: if all city / state_territory info matches, then we can assume the data is "good".
|
||||
# If not, take the safe route and just skip updating this particular record.
|
||||
request = None
|
||||
use_suborg_location_for_request = True
|
||||
use_location_for_request = True
|
||||
if requests:
|
||||
reference = requests[0]
|
||||
use_suborg_location_for_request = all(
|
||||
r.suborganization_city
|
||||
and r.suborganization_state_territory
|
||||
and r.suborganization_city == reference.suborganization_city
|
||||
and r.suborganization_state_territory == reference.suborganization_state_territory
|
||||
for r in requests
|
||||
)
|
||||
use_location_for_request = all(
|
||||
r.city
|
||||
and r.state_territory
|
||||
and r.city == reference.city
|
||||
and r.state_territory == reference.state_territory
|
||||
for r in requests
|
||||
)
|
||||
if use_suborg_location_for_request or use_location_for_request:
|
||||
request = reference
|
||||
|
||||
if not domain and not request:
|
||||
message = f"Skipping adding city / state_territory information to suborg: {suborg}. Bad data."
|
||||
TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, message)
|
||||
return
|
||||
|
||||
# PRIORITY:
|
||||
# 1. Domain info
|
||||
# 2. Domain request requested suborg fields
|
||||
# 3. Domain request normal fields
|
||||
if domain:
|
||||
suborg.city = normalize_string(domain.city, lowercase=False)
|
||||
suborg.state_territory = domain.state_territory
|
||||
elif request and use_suborg_location_for_request:
|
||||
suborg.city = normalize_string(request.suborganization_city, lowercase=False)
|
||||
suborg.state_territory = request.suborganization_state_territory
|
||||
elif request and use_location_for_request:
|
||||
suborg.city = normalize_string(request.city, lowercase=False)
|
||||
suborg.state_territory = request.state_territory
|
||||
|
||||
message = (
|
||||
f"Added city/state_territory to suborg: {suborg}. "
|
||||
f"city - {suborg.city}, state - {suborg.state_territory}"
|
||||
)
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
|
||||
|
|
133
src/registrar/management/commands/patch_suborganizations.py
Normal file
133
src/registrar/management/commands/patch_suborganizations.py
Normal file
|
@ -0,0 +1,133 @@
|
|||
import logging
|
||||
from django.core.management import BaseCommand
|
||||
from registrar.models import Suborganization, DomainRequest, DomainInformation
|
||||
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
|
||||
from registrar.models.utility.generic_helper import count_capitals, normalize_string
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Clean up duplicate suborganizations that differ only by spaces and capitalization"
|
||||
|
||||
def handle(self, **kwargs):
|
||||
"""Process manual deletions and find/remove duplicates. Shows preview
|
||||
and updates DomainInformation / DomainRequest sub_organization references before deletion."""
|
||||
|
||||
# First: get a preset list of records we want to delete.
|
||||
# For extra_records_to_prune: the key gets deleted, the value gets kept.
|
||||
extra_records_to_prune = {
|
||||
normalize_string("Assistant Secretary for Preparedness and Response Office of the Secretary"): {
|
||||
"replace_with": "Assistant Secretary for Preparedness and Response, Office of the Secretary"
|
||||
},
|
||||
normalize_string("US Geological Survey"): {"replace_with": "U.S. Geological Survey"},
|
||||
normalize_string("USDA/OC"): {"replace_with": "USDA, Office of Communications"},
|
||||
normalize_string("GSA, IC, OGP WebPortfolio"): {"replace_with": "GSA, IC, OGP Web Portfolio"},
|
||||
normalize_string("USDA/ARS/NAL"): {"replace_with": "USDA, ARS, NAL"},
|
||||
}
|
||||
|
||||
# Second: loop through every Suborganization and return a dict of what to keep, and what to delete
|
||||
# for each duplicate or "incorrect" record. We do this by pruning records with extra spaces or bad caps
|
||||
# Note that "extra_records_to_prune" is just a manual mapping.
|
||||
records_to_prune = self.get_records_to_prune(extra_records_to_prune)
|
||||
if len(records_to_prune) == 0:
|
||||
TerminalHelper.colorful_logger(logger.error, TerminalColors.FAIL, "No suborganizations to delete.")
|
||||
return
|
||||
|
||||
# Third: Build a preview of the changes
|
||||
total_records_to_remove = 0
|
||||
preview_lines = ["The following records will be removed:"]
|
||||
for data in records_to_prune.values():
|
||||
keep = data.get("keep")
|
||||
delete = data.get("delete")
|
||||
if keep:
|
||||
preview_lines.append(f"Keeping: '{keep.name}' (id: {keep.id})")
|
||||
|
||||
for duplicate in delete:
|
||||
preview_lines.append(f"Removing: '{duplicate.name}' (id: {duplicate.id})")
|
||||
total_records_to_remove += 1
|
||||
preview_lines.append("")
|
||||
preview = "\n".join(preview_lines)
|
||||
|
||||
# Fourth: Get user confirmation and delete
|
||||
if TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
prompt_message=preview,
|
||||
prompt_title=f"Remove {total_records_to_remove} suborganizations?",
|
||||
verify_message="*** WARNING: This will replace the record on DomainInformation and DomainRequest! ***",
|
||||
):
|
||||
try:
|
||||
# Update all references to point to the right suborg before deletion
|
||||
all_suborgs_to_remove = set()
|
||||
for record in records_to_prune.values():
|
||||
best_record = record["keep"]
|
||||
suborgs_to_remove = {dupe.id for dupe in record["delete"]}
|
||||
DomainRequest.objects.filter(sub_organization_id__in=suborgs_to_remove).update(
|
||||
sub_organization=best_record
|
||||
)
|
||||
DomainInformation.objects.filter(sub_organization_id__in=suborgs_to_remove).update(
|
||||
sub_organization=best_record
|
||||
)
|
||||
all_suborgs_to_remove.update(suborgs_to_remove)
|
||||
# Delete the suborgs
|
||||
delete_count, _ = Suborganization.objects.filter(id__in=all_suborgs_to_remove).delete()
|
||||
TerminalHelper.colorful_logger(
|
||||
logger.info, TerminalColors.MAGENTA, f"Successfully deleted {delete_count} suborganizations."
|
||||
)
|
||||
except Exception as e:
|
||||
TerminalHelper.colorful_logger(
|
||||
logger.error, TerminalColors.FAIL, f"Failed to delete suborganizations: {str(e)}"
|
||||
)
|
||||
|
||||
def get_records_to_prune(self, extra_records_to_prune):
|
||||
"""Maps all suborgs into a dictionary with a record to keep, and an array of records to delete."""
|
||||
# First: Group all suborganization names by their "normalized" names (finding duplicates).
|
||||
# Returns a dict that looks like this:
|
||||
# {
|
||||
# "amtrak": [<Suborganization: AMTRAK>, <Suborganization: aMtRaK>, <Suborganization: AMTRAK >],
|
||||
# "usda/oc": [<Suborganization: USDA/OC>],
|
||||
# ...etc
|
||||
# }
|
||||
#
|
||||
name_groups = {}
|
||||
for suborg in Suborganization.objects.all():
|
||||
normalized_name = normalize_string(suborg.name)
|
||||
name_groups.setdefault(normalized_name, []).append(suborg)
|
||||
|
||||
# Second: find the record we should keep, and the records we should delete
|
||||
# Returns a dict that looks like this:
|
||||
# {
|
||||
# "amtrak": {
|
||||
# "keep": <Suborganization: AMTRAK>
|
||||
# "delete": [<Suborganization: aMtRaK>, <Suborganization: AMTRAK >]
|
||||
# },
|
||||
# "usda/oc": {
|
||||
# "keep": <Suborganization: USDA, Office of Communications>,
|
||||
# "delete": [<Suborganization: USDA/OC>]
|
||||
# },
|
||||
# ...etc
|
||||
# }
|
||||
records_to_prune = {}
|
||||
for normalized_name, duplicate_suborgs in name_groups.items():
|
||||
# Delete data from our preset list
|
||||
if normalized_name in extra_records_to_prune:
|
||||
# The 'keep' field expects a Suborganization but we just pass in a string, so this is just a workaround.
|
||||
# This assumes that there is only one item in the name_group array (see usda/oc example).
|
||||
# But this should be fine, given our data.
|
||||
hardcoded_record_name = extra_records_to_prune[normalized_name]["replace_with"]
|
||||
name_group = name_groups.get(normalize_string(hardcoded_record_name))
|
||||
keep = name_group[0] if name_group else None
|
||||
records_to_prune[normalized_name] = {"keep": keep, "delete": duplicate_suborgs}
|
||||
# Delete duplicates (extra spaces or casing differences)
|
||||
elif len(duplicate_suborgs) > 1:
|
||||
# Pick the best record (fewest spaces, most leading capitals)
|
||||
best_record = max(
|
||||
duplicate_suborgs,
|
||||
key=lambda suborg: (-suborg.name.count(" "), count_capitals(suborg.name, leading_only=True)),
|
||||
)
|
||||
records_to_prune[normalized_name] = {
|
||||
"keep": best_record,
|
||||
"delete": [s for s in duplicate_suborgs if s != best_record],
|
||||
}
|
||||
return records_to_prune
|
238
src/registrar/management/commands/remove_unused_portfolios.py
Normal file
238
src/registrar/management/commands/remove_unused_portfolios.py
Normal file
|
@ -0,0 +1,238 @@
|
|||
import argparse
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import IntegrityError
|
||||
from django.db import transaction
|
||||
from registrar.management.commands.utility.terminal_helper import (
|
||||
TerminalColors,
|
||||
TerminalHelper,
|
||||
)
|
||||
from registrar.models import (
|
||||
Portfolio,
|
||||
DomainGroup,
|
||||
DomainInformation,
|
||||
DomainRequest,
|
||||
PortfolioInvitation,
|
||||
Suborganization,
|
||||
UserPortfolioPermission,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_PORTFOLIOS = [
|
||||
"Department of Veterans Affairs",
|
||||
"Department of the Treasury",
|
||||
"National Archives and Records Administration",
|
||||
"Department of Defense",
|
||||
"Office of Personnel Management",
|
||||
"National Aeronautics and Space Administration",
|
||||
"City and County of San Francisco",
|
||||
"State of Arizona, Executive Branch",
|
||||
"Department of the Interior",
|
||||
"Department of State",
|
||||
"Department of Justice",
|
||||
"Capitol Police",
|
||||
"Administrative Office of the Courts",
|
||||
"Supreme Court of the United States",
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Remove all Portfolio entries with names not in the allowed list."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""
|
||||
OPTIONAL ARGUMENTS:
|
||||
--debug
|
||||
A boolean (default to true), which activates additional print statements
|
||||
"""
|
||||
parser.add_argument("--debug", action=argparse.BooleanOptionalAction)
|
||||
|
||||
def prompt_delete_entries(self, portfolios_to_delete, debug_on):
|
||||
"""Brings up a prompt in the terminal asking
|
||||
if the user wishes to delete data in the
|
||||
Portfolio table. If the user confirms,
|
||||
deletes the data in the Portfolio table"""
|
||||
|
||||
entries_to_remove_by_name = list(portfolios_to_delete.values_list("organization_name", flat=True))
|
||||
formatted_entries = "\n\t\t".join(entries_to_remove_by_name)
|
||||
confirm_delete = TerminalHelper.query_yes_no(
|
||||
f"""
|
||||
{TerminalColors.FAIL}
|
||||
WARNING: You are about to delete the following portfolios:
|
||||
|
||||
{formatted_entries}
|
||||
|
||||
Are you sure you want to continue?{TerminalColors.ENDC}"""
|
||||
)
|
||||
if confirm_delete:
|
||||
logger.info(
|
||||
f"""{TerminalColors.YELLOW}
|
||||
----------Deleting entries----------
|
||||
(please wait)
|
||||
{TerminalColors.ENDC}"""
|
||||
)
|
||||
self.delete_entries(portfolios_to_delete, debug_on)
|
||||
else:
|
||||
logger.info(
|
||||
f"""{TerminalColors.OKCYAN}
|
||||
----------No entries deleted----------
|
||||
(exiting script)
|
||||
{TerminalColors.ENDC}"""
|
||||
)
|
||||
|
||||
def delete_entries(self, portfolios_to_delete, debug_on): # noqa: C901
|
||||
# Log the number of entries being removed
|
||||
count = portfolios_to_delete.count()
|
||||
if count == 0:
|
||||
logger.info(
|
||||
f"""{TerminalColors.OKCYAN}
|
||||
No entries to remove.
|
||||
{TerminalColors.ENDC}
|
||||
"""
|
||||
)
|
||||
return
|
||||
|
||||
# If debug mode is on, print out entries being removed
|
||||
if debug_on:
|
||||
entries_to_remove_by_name = list(portfolios_to_delete.values_list("organization_name", flat=True))
|
||||
formatted_entries = ", ".join(entries_to_remove_by_name)
|
||||
logger.info(
|
||||
f"""{TerminalColors.YELLOW}
|
||||
Entries to be removed: {formatted_entries}
|
||||
{TerminalColors.ENDC}
|
||||
"""
|
||||
)
|
||||
|
||||
# Check for portfolios with non-empty related objects
|
||||
# (These will throw integrity errors if they are not updated)
|
||||
portfolios_with_assignments = []
|
||||
for portfolio in portfolios_to_delete:
|
||||
has_assignments = any(
|
||||
[
|
||||
DomainGroup.objects.filter(portfolio=portfolio).exists(),
|
||||
DomainInformation.objects.filter(portfolio=portfolio).exists(),
|
||||
DomainRequest.objects.filter(portfolio=portfolio).exists(),
|
||||
PortfolioInvitation.objects.filter(portfolio=portfolio).exists(),
|
||||
Suborganization.objects.filter(portfolio=portfolio).exists(),
|
||||
UserPortfolioPermission.objects.filter(portfolio=portfolio).exists(),
|
||||
]
|
||||
)
|
||||
if has_assignments:
|
||||
portfolios_with_assignments.append(portfolio)
|
||||
|
||||
if portfolios_with_assignments:
|
||||
formatted_entries = "\n\t\t".join(
|
||||
f"{portfolio.organization_name}" for portfolio in portfolios_with_assignments
|
||||
)
|
||||
confirm_cascade_delete = TerminalHelper.query_yes_no(
|
||||
f"""
|
||||
{TerminalColors.FAIL}
|
||||
WARNING: these entries have related objects.
|
||||
|
||||
{formatted_entries}
|
||||
|
||||
Deleting them will update any associated domains / domain requests to have no portfolio
|
||||
and will cascade delete any associated portfolio invitations, portfolio permissions, domain groups,
|
||||
and suborganizations. Any suborganizations that get deleted will also orphan (not delete) their
|
||||
associated domains / domain requests.
|
||||
|
||||
Are you sure you want to continue?{TerminalColors.ENDC}"""
|
||||
)
|
||||
if not confirm_cascade_delete:
|
||||
logger.info(
|
||||
f"""{TerminalColors.OKCYAN}
|
||||
Operation canceled by the user.
|
||||
{TerminalColors.ENDC}
|
||||
"""
|
||||
)
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# Try to delete the portfolios
|
||||
try:
|
||||
summary = []
|
||||
for portfolio in portfolios_to_delete:
|
||||
portfolio_summary = [f"---- CASCADE SUMMARY for {portfolio.organization_name} -----"]
|
||||
if portfolio in portfolios_with_assignments:
|
||||
domain_groups = DomainGroup.objects.filter(portfolio=portfolio)
|
||||
domain_informations = DomainInformation.objects.filter(portfolio=portfolio)
|
||||
domain_requests = DomainRequest.objects.filter(portfolio=portfolio)
|
||||
portfolio_invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
|
||||
suborganizations = Suborganization.objects.filter(portfolio=portfolio)
|
||||
user_permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio)
|
||||
|
||||
if domain_groups.exists():
|
||||
formatted_groups = "\n".join([str(group) for group in domain_groups])
|
||||
portfolio_summary.append(f"{len(domain_groups)} Deleted DomainGroups:\n{formatted_groups}")
|
||||
domain_groups.delete()
|
||||
|
||||
if domain_informations.exists():
|
||||
formatted_domain_infos = "\n".join([str(info) for info in domain_informations])
|
||||
portfolio_summary.append(
|
||||
f"{len(domain_informations)} Orphaned DomainInformations:\n{formatted_domain_infos}"
|
||||
)
|
||||
domain_informations.update(portfolio=None)
|
||||
|
||||
if domain_requests.exists():
|
||||
formatted_domain_reqs = "\n".join([str(req) for req in domain_requests])
|
||||
portfolio_summary.append(
|
||||
f"{len(domain_requests)} Orphaned DomainRequests:\n{formatted_domain_reqs}"
|
||||
)
|
||||
domain_requests.update(portfolio=None)
|
||||
|
||||
if portfolio_invitations.exists():
|
||||
formatted_portfolio_invitations = "\n".join([str(inv) for inv in portfolio_invitations])
|
||||
portfolio_summary.append(
|
||||
f"{len(portfolio_invitations)} Deleted PortfolioInvitations:\n{formatted_portfolio_invitations}" # noqa
|
||||
)
|
||||
portfolio_invitations.delete()
|
||||
|
||||
if user_permissions.exists():
|
||||
formatted_user_list = "\n".join(
|
||||
[perm.user.get_formatted_name() for perm in user_permissions]
|
||||
)
|
||||
portfolio_summary.append(
|
||||
f"Deleted UserPortfolioPermissions for the following users:\n{formatted_user_list}"
|
||||
)
|
||||
user_permissions.delete()
|
||||
|
||||
if suborganizations.exists():
|
||||
portfolio_summary.append("Cascade Deleted Suborganizations:")
|
||||
for suborg in suborganizations:
|
||||
DomainInformation.objects.filter(sub_organization=suborg).update(sub_organization=None)
|
||||
DomainRequest.objects.filter(sub_organization=suborg).update(sub_organization=None)
|
||||
portfolio_summary.append(f"{suborg.name}")
|
||||
suborg.delete()
|
||||
|
||||
portfolio.delete()
|
||||
summary.append("\n\n".join(portfolio_summary))
|
||||
summary_string = "\n\n".join(summary)
|
||||
|
||||
# Output a success message with detailed summary
|
||||
logger.info(
|
||||
f"""{TerminalColors.OKCYAN}
|
||||
Successfully removed {count} portfolios.
|
||||
|
||||
The following portfolio deletions had cascading effects;
|
||||
|
||||
{summary_string}
|
||||
{TerminalColors.ENDC}
|
||||
"""
|
||||
)
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.info(
|
||||
f"""{TerminalColors.FAIL}
|
||||
Could not delete some portfolios due to integrity constraints:
|
||||
{e}
|
||||
{TerminalColors.ENDC}
|
||||
"""
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get all Portfolio entries not in the allowed portfolios list
|
||||
portfolios_to_delete = Portfolio.objects.exclude(organization_name__in=ALLOWED_PORTFOLIOS)
|
||||
|
||||
self.prompt_delete_entries(portfolios_to_delete, options.get("debug"))
|
|
@ -401,16 +401,15 @@ class TerminalHelper:
|
|||
# Allow the user to inspect the command string
|
||||
# and ask if they wish to proceed
|
||||
proceed_execution = TerminalHelper.query_yes_no_exit(
|
||||
f"""{TerminalColors.OKCYAN}
|
||||
=====================================================
|
||||
{prompt_title}
|
||||
=====================================================
|
||||
{verify_message}
|
||||
|
||||
{prompt_message}
|
||||
{TerminalColors.FAIL}
|
||||
Proceed? (Y = proceed, N = {action_description_for_selecting_no})
|
||||
{TerminalColors.ENDC}"""
|
||||
f"\n{TerminalColors.OKCYAN}"
|
||||
"====================================================="
|
||||
f"\n{prompt_title}\n"
|
||||
"====================================================="
|
||||
f"\n{verify_message}\n"
|
||||
f"\n{prompt_message}\n"
|
||||
f"{TerminalColors.FAIL}"
|
||||
f"Proceed? (Y = proceed, N = {action_description_for_selecting_no})"
|
||||
f"{TerminalColors.ENDC}"
|
||||
)
|
||||
|
||||
# If the user decided to proceed return true.
|
||||
|
@ -443,13 +442,14 @@ class TerminalHelper:
|
|||
f.write(file_contents)
|
||||
|
||||
@staticmethod
|
||||
def colorful_logger(log_level, color, message):
|
||||
def colorful_logger(log_level, color, message, exc_info=True):
|
||||
"""Adds some color to your log output.
|
||||
|
||||
Args:
|
||||
log_level: str | Logger.method -> Desired log level. ex: logger.info or "INFO"
|
||||
color: str | TerminalColors -> Output color. ex: TerminalColors.YELLOW or "YELLOW"
|
||||
message: str -> Message to display.
|
||||
exc_info: bool -> Whether the log should print exc_info or not
|
||||
"""
|
||||
|
||||
if isinstance(log_level, str) and hasattr(logger, log_level.lower()):
|
||||
|
@ -463,4 +463,4 @@ class TerminalHelper:
|
|||
terminal_color = color
|
||||
|
||||
colored_message = f"{terminal_color}{message}{TerminalColors.ENDC}"
|
||||
log_method(colored_message)
|
||||
log_method(colored_message, exc_info=exc_info)
|
||||
|
|
|
@ -2,11 +2,11 @@ from itertools import zip_longest
|
|||
import logging
|
||||
import ipaddress
|
||||
import re
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
from django.db import transaction
|
||||
from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
|
||||
|
||||
from django.db import models
|
||||
from django.db import models, IntegrityError
|
||||
from django.utils import timezone
|
||||
from typing import Any
|
||||
from registrar.models.host import Host
|
||||
|
@ -40,6 +40,7 @@ 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__)
|
||||
|
||||
|
@ -325,9 +326,8 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
exp_date = self.registry_expiration_date
|
||||
except KeyError:
|
||||
# if no expiration date from registry, set it to today
|
||||
logger.warning("current expiration date not set; setting to today")
|
||||
logger.warning("current expiration date not set; setting to today", exc_info=True)
|
||||
exp_date = date.today()
|
||||
|
||||
# create RenewDomain request
|
||||
request = commands.RenewDomain(name=self.name, cur_exp_date=exp_date, period=epp.Period(length, unit))
|
||||
|
||||
|
@ -337,13 +337,14 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
self._cache["ex_date"] = registry.send(request, cleaned=True).res_data[0].ex_date
|
||||
self.expiration_date = self._cache["ex_date"]
|
||||
self.save()
|
||||
|
||||
except RegistryError as err:
|
||||
# if registry error occurs, log the error, and raise it as well
|
||||
logger.error(f"registry error renewing domain: {err}")
|
||||
logger.error(f"Registry error renewing domain '{self.name}': {err}")
|
||||
raise (err)
|
||||
except Exception as e:
|
||||
# exception raised during the save to registrar
|
||||
logger.error(f"error updating expiration date in registrar: {e}")
|
||||
logger.error(f"Error updating expiration date for domain '{self.name}' in registrar: {e}")
|
||||
raise (e)
|
||||
|
||||
@Cache
|
||||
|
@ -1152,13 +1153,28 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
now = timezone.now().date()
|
||||
return self.expiration_date < now
|
||||
|
||||
def state_display(self):
|
||||
def is_expiring(self):
|
||||
"""
|
||||
Check if the domain's expiration date is within 60 days.
|
||||
Return True if domain expiration date exists and within 60 days
|
||||
and otherwise False bc there's no expiration date meaning so not expiring
|
||||
"""
|
||||
if self.expiration_date is None:
|
||||
return False
|
||||
|
||||
now = timezone.now().date()
|
||||
|
||||
threshold_date = now + timedelta(days=60)
|
||||
return now < self.expiration_date <= threshold_date
|
||||
|
||||
def state_display(self, request=None):
|
||||
"""Return the display status of the domain."""
|
||||
if self.is_expired() and self.state != self.State.UNKNOWN:
|
||||
if self.is_expired() and (self.state != self.State.UNKNOWN):
|
||||
return "Expired"
|
||||
elif flag_is_active(request, "domain_renewal") and self.is_expiring():
|
||||
return "Expiring soon"
|
||||
elif self.state == self.State.UNKNOWN or self.state == self.State.DNS_NEEDED:
|
||||
return "DNS needed"
|
||||
else:
|
||||
return self.state.capitalize()
|
||||
|
||||
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
|
||||
|
@ -1313,14 +1329,14 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
|
||||
def get_default_administrative_contact(self):
|
||||
"""Gets the default administrative contact."""
|
||||
logger.info("get_default_security_contact() -> Adding administrative security contact")
|
||||
logger.info("get_default_administrative_contact() -> Adding default administrative contact")
|
||||
contact = PublicContact.get_default_administrative()
|
||||
contact.domain = self
|
||||
return contact
|
||||
|
||||
def get_default_technical_contact(self):
|
||||
"""Gets the default technical contact."""
|
||||
logger.info("get_default_security_contact() -> Adding technical security contact")
|
||||
logger.info("get_default_security_contact() -> Adding default technical contact")
|
||||
contact = PublicContact.get_default_technical()
|
||||
contact.domain = self
|
||||
return contact
|
||||
|
@ -1559,16 +1575,16 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
logger.info("Changing to DNS_NEEDED state")
|
||||
logger.info("able to transition to DNS_NEEDED state")
|
||||
|
||||
def get_state_help_text(self) -> str:
|
||||
def get_state_help_text(self, request=None) -> str:
|
||||
"""Returns a str containing additional information about a given state.
|
||||
Returns custom content for when the domain itself is expired."""
|
||||
|
||||
if self.is_expired() and self.state != self.State.UNKNOWN:
|
||||
# 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, but it is still online. " "To renew this domain, contact help@get.gov."
|
||||
)
|
||||
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():
|
||||
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)
|
||||
|
||||
|
@ -1660,9 +1676,11 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
for domainContact in contact_data:
|
||||
req = commands.InfoContact(id=domainContact.contact)
|
||||
data = registry.send(req, cleaned=True).res_data[0]
|
||||
logger.info(f"_fetch_contacts => this is the data: {data}")
|
||||
|
||||
# Map the object we recieved from EPP to a PublicContact
|
||||
mapped_object = self.map_epp_contact_to_public_contact(data, domainContact.contact, domainContact.type)
|
||||
logger.info(f"_fetch_contacts => mapped_object: {mapped_object}")
|
||||
|
||||
# Find/create it in the DB
|
||||
in_db = self._get_or_create_public_contact(mapped_object)
|
||||
|
@ -1853,8 +1871,9 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
missingSecurity = True
|
||||
missingTech = True
|
||||
|
||||
if len(cleaned.get("_contacts")) < 3:
|
||||
for contact in cleaned.get("_contacts"):
|
||||
contacts = cleaned.get("_contacts", [])
|
||||
if len(contacts) < 3:
|
||||
for contact in contacts:
|
||||
if contact.type == PublicContact.ContactTypeChoices.ADMINISTRATIVE:
|
||||
missingAdmin = False
|
||||
if contact.type == PublicContact.ContactTypeChoices.SECURITY:
|
||||
|
@ -1873,6 +1892,11 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
technical_contact = self.get_default_technical_contact()
|
||||
technical_contact.save()
|
||||
|
||||
logger.info(
|
||||
"_add_missing_contacts_if_unknown => Adding contacts. Values are "
|
||||
f"missingAdmin: {missingAdmin}, missingSecurity: {missingSecurity}, missingTech: {missingTech}"
|
||||
)
|
||||
|
||||
def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False):
|
||||
"""Contact registry for info about a domain."""
|
||||
try:
|
||||
|
@ -2086,8 +2110,21 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
# Save to DB if it doesn't exist already.
|
||||
if db_contact.count() == 0:
|
||||
# Doesn't run custom save logic, just saves to DB
|
||||
try:
|
||||
with transaction.atomic():
|
||||
public_contact.save(skip_epp_save=True)
|
||||
logger.info(f"Created a new PublicContact: {public_contact}")
|
||||
except IntegrityError as err:
|
||||
logger.error(
|
||||
f"_get_or_create_public_contact() => tried to create a duplicate public contact: {err}",
|
||||
exc_info=True,
|
||||
)
|
||||
return PublicContact.objects.get(
|
||||
registry_id=public_contact.registry_id,
|
||||
contact_type=public_contact.contact_type,
|
||||
domain=self,
|
||||
)
|
||||
|
||||
# Append the item we just created
|
||||
return public_contact
|
||||
|
||||
|
@ -2097,7 +2134,7 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
if existing_contact.email != public_contact.email or existing_contact.registry_id != public_contact.registry_id:
|
||||
existing_contact.delete()
|
||||
public_contact.save()
|
||||
logger.warning("Requested PublicContact is out of sync " "with DB.")
|
||||
logger.warning("Requested PublicContact is out of sync with DB.")
|
||||
return public_contact
|
||||
|
||||
# If it already exists, we can assume that the DB instance was updated during set, so we should just use that.
|
||||
|
|
|
@ -101,7 +101,6 @@ class DomainInformation(TimeStampedModel):
|
|||
verbose_name="election office",
|
||||
)
|
||||
|
||||
# TODO - Ticket #1911: stub this data from DomainRequest
|
||||
organization_type = models.CharField(
|
||||
max_length=255,
|
||||
choices=DomainRequest.OrgChoicesElectionOffice.choices,
|
||||
|
|
|
@ -9,9 +9,11 @@ from django.utils import timezone
|
|||
from registrar.models.domain import Domain
|
||||
from registrar.models.federal_agency import FederalAgency
|
||||
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices
|
||||
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from auditlog.models import LogEntry
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from registrar.utility.waffle import flag_is_active_for_user
|
||||
|
||||
|
@ -671,6 +673,59 @@ class DomainRequest(TimeStampedModel):
|
|||
# Store original values for caching purposes. Used to compare them on save.
|
||||
self._cache_status_and_status_reasons()
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validates suborganization-related fields in two scenarios:
|
||||
1. New suborganization request: Prevents duplicate names within same portfolio
|
||||
2. Partial suborganization data: Enforces a all-or-nothing rule for city/state/name fields
|
||||
when portfolio exists without selected suborganization
|
||||
|
||||
Add new domain request validation rules here to ensure they're
|
||||
enforced during both model save and form submission.
|
||||
Not presently used on the domain request wizard, though.
|
||||
"""
|
||||
super().clean()
|
||||
# Validation logic for a suborganization request
|
||||
if self.is_requesting_new_suborganization():
|
||||
# Raise an error if this suborganization already exists
|
||||
Suborganization = apps.get_model("registrar.Suborganization")
|
||||
if (
|
||||
self.requested_suborganization
|
||||
and Suborganization.objects.filter(
|
||||
name__iexact=self.requested_suborganization,
|
||||
portfolio=self.portfolio,
|
||||
name__isnull=False,
|
||||
portfolio__isnull=False,
|
||||
).exists()
|
||||
):
|
||||
# Add a field-level error to requested_suborganization.
|
||||
# To pass in field-specific errors, we need to embed a dict of
|
||||
# field: validationerror then pass that into a validation error itself.
|
||||
# This is slightly confusing, but it just adds it at that level.
|
||||
msg = (
|
||||
"This suborganization already exists. "
|
||||
"Choose a new name, or select it directly if you would like to use it."
|
||||
)
|
||||
errors = {"requested_suborganization": ValidationError(msg)}
|
||||
raise ValidationError(errors)
|
||||
elif self.portfolio and not self.sub_organization:
|
||||
# You cannot create a new suborganization without these fields
|
||||
required_suborg_fields = {
|
||||
"requested_suborganization": self.requested_suborganization,
|
||||
"suborganization_city": self.suborganization_city,
|
||||
"suborganization_state_territory": self.suborganization_state_territory,
|
||||
}
|
||||
# If at least one value is populated, enforce a all-or-nothing rule
|
||||
if any(bool(value) for value in required_suborg_fields.values()):
|
||||
# Find which fields are empty and throw an error on the field
|
||||
errors = {}
|
||||
for field_name, value in required_suborg_fields.items():
|
||||
if not value:
|
||||
errors[field_name] = ValidationError(
|
||||
"This field is required when creating a new suborganization.",
|
||||
)
|
||||
raise ValidationError(errors)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Save override for custom properties"""
|
||||
self.sync_organization_type()
|
||||
|
@ -690,6 +745,18 @@ class DomainRequest(TimeStampedModel):
|
|||
# Update the cached values after saving
|
||||
self._cache_status_and_status_reasons()
|
||||
|
||||
def create_requested_suborganization(self):
|
||||
"""Creates the requested suborganization.
|
||||
Adds the name, portfolio, city, and state_territory fields.
|
||||
Returns the created suborganization."""
|
||||
Suborganization = apps.get_model("registrar.Suborganization")
|
||||
return Suborganization.objects.create(
|
||||
name=self.requested_suborganization,
|
||||
portfolio=self.portfolio,
|
||||
city=self.suborganization_city,
|
||||
state_territory=self.suborganization_state_territory,
|
||||
)
|
||||
|
||||
def send_custom_status_update_email(self, status):
|
||||
"""Helper function to send out a second status email when the status remains the same,
|
||||
but the reason has changed."""
|
||||
|
@ -784,7 +851,9 @@ class DomainRequest(TimeStampedModel):
|
|||
return True
|
||||
|
||||
def delete_and_clean_up_domain(self, called_from):
|
||||
# Delete the approved domain
|
||||
try:
|
||||
# Clean up the approved domain
|
||||
domain_state = self.approved_domain.state
|
||||
# Only reject if it exists on EPP
|
||||
if domain_state != Domain.State.UNKNOWN:
|
||||
|
@ -796,12 +865,46 @@ class DomainRequest(TimeStampedModel):
|
|||
logger.error(err)
|
||||
logger.error(f"Can't query an approved domain while attempting {called_from}")
|
||||
|
||||
# Delete the suborg as long as this is the only place it is used
|
||||
self._cleanup_dangling_suborg()
|
||||
|
||||
def _cleanup_dangling_suborg(self):
|
||||
"""Deletes the existing suborg if its only being used by the deleted record"""
|
||||
# Nothing to delete, so we just smile and walk away
|
||||
if self.sub_organization is None:
|
||||
return
|
||||
|
||||
Suborganization = apps.get_model("registrar.Suborganization")
|
||||
|
||||
# Stored as so because we need to set the reference to none first,
|
||||
# so we can't just use the self.sub_organization property
|
||||
suborg = Suborganization.objects.get(id=self.sub_organization.id)
|
||||
requests = suborg.request_sub_organization
|
||||
domain_infos = suborg.information_sub_organization
|
||||
|
||||
# Check if this is the only reference to the suborganization
|
||||
if requests.count() != 1 or domain_infos.count() > 1:
|
||||
return
|
||||
|
||||
# Remove the suborganization reference from request.
|
||||
self.sub_organization = None
|
||||
self.save()
|
||||
|
||||
# Remove the suborganization reference from domain if it exists.
|
||||
if domain_infos.count() == 1:
|
||||
domain_infos.update(sub_organization=None)
|
||||
|
||||
# Delete the now-orphaned suborganization
|
||||
logger.info(f"_cleanup_dangling_suborg() -> Deleting orphan suborganization: {suborg}")
|
||||
suborg.delete()
|
||||
|
||||
def _send_status_update_email(
|
||||
self,
|
||||
new_status,
|
||||
email_template,
|
||||
email_template_subject,
|
||||
bcc_address="",
|
||||
cc_addresses: list[str] = [],
|
||||
context=None,
|
||||
send_email=True,
|
||||
wrap_email=False,
|
||||
|
@ -854,12 +957,20 @@ class DomainRequest(TimeStampedModel):
|
|||
|
||||
if custom_email_content:
|
||||
context["custom_email_content"] = custom_email_content
|
||||
|
||||
if self.requesting_entity_is_portfolio() or self.requesting_entity_is_suborganization():
|
||||
portfolio_view_requests_users = self.portfolio.portfolio_users_with_permissions( # type: ignore
|
||||
permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], include_admin=True
|
||||
)
|
||||
cc_addresses = list(portfolio_view_requests_users.values_list("email", flat=True))
|
||||
|
||||
send_templated_email(
|
||||
email_template,
|
||||
email_template_subject,
|
||||
recipient.email,
|
||||
context=context,
|
||||
bcc_address=bcc_address,
|
||||
cc_addresses=cc_addresses,
|
||||
wrap_email=wrap_email,
|
||||
)
|
||||
logger.info(f"The {new_status} email sent to: {recipient.email}")
|
||||
|
@ -984,6 +1095,7 @@ class DomainRequest(TimeStampedModel):
|
|||
|
||||
if self.status == self.DomainRequestStatus.APPROVED:
|
||||
self.delete_and_clean_up_domain("action_needed")
|
||||
|
||||
elif self.status == self.DomainRequestStatus.REJECTED:
|
||||
self.rejection_reason = None
|
||||
|
||||
|
@ -1014,8 +1126,16 @@ class DomainRequest(TimeStampedModel):
|
|||
domain request into an admin on that domain. It also triggers an email
|
||||
notification."""
|
||||
|
||||
should_save = False
|
||||
if self.federal_agency is None:
|
||||
self.federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first()
|
||||
should_save = True
|
||||
|
||||
if self.is_requesting_new_suborganization():
|
||||
self.sub_organization = self.create_requested_suborganization()
|
||||
should_save = True
|
||||
|
||||
if should_save:
|
||||
self.save()
|
||||
|
||||
# create the domain
|
||||
|
@ -1148,7 +1268,7 @@ class DomainRequest(TimeStampedModel):
|
|||
def is_requesting_new_suborganization(self) -> bool:
|
||||
"""Determines if a user is trying to request
|
||||
a new suborganization using the domain request form, rather than one that already exists.
|
||||
Used for the RequestingEntity page.
|
||||
Used for the RequestingEntity page and on DomainInformation.create_from_da().
|
||||
|
||||
Returns True if a sub_organization does not exist and if requested_suborganization,
|
||||
suborganization_city, and suborganization_state_territory all exist.
|
||||
|
|
|
@ -4,6 +4,7 @@ from registrar.models.domain_request import DomainRequest
|
|||
from registrar.models.federal_agency import FederalAgency
|
||||
from registrar.models.user import User
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from django.db.models import Q
|
||||
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
||||
|
@ -122,6 +123,16 @@ class Portfolio(TimeStampedModel):
|
|||
if self.state_territory != self.StateTerritoryChoices.PUERTO_RICO and self.urbanization:
|
||||
self.urbanization = None
|
||||
|
||||
# If the org type is federal, and org federal agency is not blank, and is a federal agency
|
||||
# overwrite the organization name with the federal agency's agency
|
||||
if (
|
||||
self.organization_type == self.OrganizationChoices.FEDERAL
|
||||
and self.federal_agency
|
||||
and self.federal_agency != FederalAgency.get_non_federal_agency()
|
||||
and self.federal_agency.agency
|
||||
):
|
||||
self.organization_name = self.federal_agency.agency
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
|
@ -144,6 +155,25 @@ class Portfolio(TimeStampedModel):
|
|||
).values_list("user__id", flat=True)
|
||||
return User.objects.filter(id__in=admin_ids)
|
||||
|
||||
def portfolio_users_with_permissions(self, permissions=[], include_admin=False):
|
||||
"""Gets all users with specified additional permissions for this particular portfolio.
|
||||
Returns a queryset of User."""
|
||||
portfolio_users = self.portfolio_users
|
||||
if permissions:
|
||||
if include_admin:
|
||||
portfolio_users = portfolio_users.filter(
|
||||
Q(additional_permissions__overlap=permissions)
|
||||
| Q(
|
||||
roles__overlap=[
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
]
|
||||
),
|
||||
)
|
||||
else:
|
||||
portfolio_users = portfolio_users.filter(additional_permissions__overlap=permissions)
|
||||
user_ids = portfolio_users.values_list("user__id", flat=True)
|
||||
return User.objects.filter(id__in=user_ids)
|
||||
|
||||
# == Getters for domains == #
|
||||
def get_domains(self, order_by=None):
|
||||
"""Returns all DomainInformations associated with this portfolio"""
|
||||
|
|
|
@ -55,7 +55,9 @@ class SeniorOfficial(TimeStampedModel):
|
|||
return " ".join(names) if names else "Unknown"
|
||||
|
||||
def __str__(self):
|
||||
if self.first_name or self.last_name:
|
||||
if self.federal_agency and (self.first_name or self.last_name):
|
||||
return self.get_formatted_name() + " of " + self.federal_agency.__str__()
|
||||
elif self.first_name or self.last_name:
|
||||
return self.get_formatted_name()
|
||||
elif self.pk:
|
||||
return str(self.pk)
|
||||
|
|
|
@ -14,6 +14,8 @@ from .domain import Domain
|
|||
from .domain_request import DomainRequest
|
||||
from registrar.utility.waffle import flag_is_active_for_user
|
||||
from waffle.decorators import flag_is_active
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
|
||||
|
||||
|
@ -163,6 +165,23 @@ class User(AbstractUser):
|
|||
active_requests_count = self.domain_requests_created.filter(status__in=allowed_states).count()
|
||||
return active_requests_count
|
||||
|
||||
def get_num_expiring_domains(self, request):
|
||||
"""Return number of expiring domains"""
|
||||
domain_ids = self.get_user_domain_ids(request)
|
||||
now = timezone.now().date()
|
||||
expiration_window = 60
|
||||
threshold_date = now + timedelta(days=expiration_window)
|
||||
acceptable_statuses = [Domain.State.UNKNOWN, Domain.State.DNS_NEEDED, Domain.State.READY]
|
||||
|
||||
num_of_expiring_domains = Domain.objects.filter(
|
||||
id__in=domain_ids,
|
||||
expiration_date__isnull=False,
|
||||
expiration_date__lte=threshold_date,
|
||||
expiration_date__gt=now,
|
||||
state__in=acceptable_statuses,
|
||||
).count()
|
||||
return num_of_expiring_domains
|
||||
|
||||
def get_rejected_requests_count(self):
|
||||
"""Return count of rejected requests"""
|
||||
return self.domain_requests_created.filter(status=DomainRequest.DomainRequestStatus.REJECTED).count()
|
||||
|
@ -259,6 +278,9 @@ class User(AbstractUser):
|
|||
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")
|
||||
|
||||
def get_first_portfolio(self):
|
||||
permission = self.portfolio_permissions.first()
|
||||
if permission:
|
||||
|
|
|
@ -21,16 +21,18 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||
# Domain: field specific permissions
|
||||
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
||||
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
|
||||
],
|
||||
# NOTE: Check FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS before adding roles here.
|
||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
||||
],
|
||||
}
|
||||
|
||||
|
@ -38,9 +40,9 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
# Used to throw a ValidationError on clean() for UserPortfolioPermission and PortfolioInvitation.
|
||||
FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS = {
|
||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
|
||||
],
|
||||
}
|
||||
|
||||
|
@ -110,8 +112,13 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
return self.get_portfolio_permissions(self.roles, self.additional_permissions)
|
||||
|
||||
@classmethod
|
||||
def get_portfolio_permissions(cls, roles, additional_permissions):
|
||||
"""Class method to return a list of permissions based on roles and addtl permissions"""
|
||||
def get_portfolio_permissions(cls, roles, additional_permissions, get_list=True):
|
||||
"""Class method to return a list of permissions based on roles and addtl permissions.
|
||||
Params:
|
||||
roles => An array of roles
|
||||
additional_permissions => An array of additional_permissions
|
||||
get_list => If true, returns a list of perms. If false, returns a set of perms.
|
||||
"""
|
||||
# Use a set to avoid duplicate permissions
|
||||
portfolio_permissions = set()
|
||||
if roles:
|
||||
|
@ -119,7 +126,7 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||
if additional_permissions:
|
||||
portfolio_permissions.update(additional_permissions)
|
||||
return list(portfolio_permissions)
|
||||
return list(portfolio_permissions) if get_list else portfolio_permissions
|
||||
|
||||
@classmethod
|
||||
def get_domain_request_permission_display(cls, roles, additional_permissions):
|
||||
|
@ -166,8 +173,10 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
# The solution to this is to only grab what is only COMMONLY "forbidden".
|
||||
# This will scale if we add more roles in the future.
|
||||
# This is thes same as applying the `&` operator across all sets for each role.
|
||||
common_forbidden_perms = set.intersection(
|
||||
*[set(cls.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) for role in roles]
|
||||
common_forbidden_perms = (
|
||||
set.intersection(*[set(cls.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) for role in roles])
|
||||
if roles
|
||||
else set()
|
||||
)
|
||||
|
||||
# Check if the users current permissions overlap with any forbidden permissions
|
||||
|
|
|
@ -15,9 +15,11 @@ class DomainHelper:
|
|||
|
||||
# a domain name is alphanumeric or hyphen, up to 63 characters, doesn't
|
||||
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
|
||||
DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.[A-Za-z]{2,6}$")
|
||||
DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,200}(?<!-)\.[A-Za-z]{2,6}$")
|
||||
|
||||
# a domain can be no longer than 253 characters in total
|
||||
# NOTE: the domain name is limited by the DOMAIN_REGEX above
|
||||
# to 200 characters (not including the .gov at the end)
|
||||
MAX_LENGTH = 253
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -343,3 +343,27 @@ def value_of_attribute(obj, attribute_name: str):
|
|||
if callable(value):
|
||||
value = value()
|
||||
return value
|
||||
|
||||
|
||||
def normalize_string(string_to_normalize, lowercase=True):
|
||||
"""Normalizes a given string. Returns a string without extra spaces, in all lowercase."""
|
||||
if not isinstance(string_to_normalize, str):
|
||||
logger.error(f"normalize_string => {string_to_normalize} is not type str.")
|
||||
return string_to_normalize
|
||||
|
||||
new_string = " ".join(string_to_normalize.split())
|
||||
return new_string.lower() if lowercase else new_string
|
||||
|
||||
|
||||
def count_capitals(text: str, leading_only: bool):
|
||||
"""Counts capital letters in a string.
|
||||
Args:
|
||||
text (str): The string to analyze.
|
||||
leading_only (bool): If False, counts all capital letters.
|
||||
If True, only counts capitals at the start of words.
|
||||
Returns:
|
||||
int: Number of capital letters found.
|
||||
"""
|
||||
if leading_only:
|
||||
return sum(word[0].isupper() for word in text.split() if word)
|
||||
return sum(c.isupper() for c in text if c)
|
||||
|
|
|
@ -4,6 +4,9 @@ from django.apps import apps
|
|||
from django.forms import ValidationError
|
||||
from registrar.utility.waffle import flag_is_active_for_user
|
||||
from django.contrib.auth import get_user_model
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserPortfolioRoleChoices(models.TextChoices):
|
||||
|
@ -16,7 +19,11 @@ class UserPortfolioRoleChoices(models.TextChoices):
|
|||
|
||||
@classmethod
|
||||
def get_user_portfolio_role_label(cls, user_portfolio_role):
|
||||
try:
|
||||
return cls(user_portfolio_role).label if user_portfolio_role else None
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid portfolio role: {user_portfolio_role}")
|
||||
return f"Unknown ({user_portfolio_role})"
|
||||
|
||||
|
||||
class UserPortfolioPermissionChoices(models.TextChoices):
|
||||
|
@ -129,7 +136,9 @@ def validate_user_portfolio_permission(user_portfolio_permission):
|
|||
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
|
||||
)
|
||||
|
||||
existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email)
|
||||
existing_invitations = PortfolioInvitation.objects.exclude(
|
||||
portfolio=user_portfolio_permission.portfolio
|
||||
).filter(email=user_portfolio_permission.user.email)
|
||||
if existing_invitations.exists():
|
||||
raise ValidationError(
|
||||
"This user is already assigned to a portfolio invitation. "
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
{% block title %}{% translate "Unauthorized | " %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
<div class="grid-row grow-gap">
|
||||
<main id="main-content" class="grid-container grid-container--widescreen">
|
||||
<div class="grid-row grow-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
|
||||
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
|
||||
<h1>
|
||||
{% translate "You are not authorized to view this page" %}
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
{% block title %}{% translate "Forbidden | " %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
<div class="grid-row grow-gap">
|
||||
<main id="main-content" class="grid-container grid-container--widescreen">
|
||||
<div class="grid-row grow-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
|
||||
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
|
||||
<h1>
|
||||
{% translate "You're not authorized to view this page." %}
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
{% block title %}{% translate "Page not found | " %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
<div class="grid-row grid-gap">
|
||||
<main id="main-content" class="grid-container grid-container--widescreen">
|
||||
<div class="grid-row grid-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
|
||||
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
|
||||
<h1>
|
||||
{% translate "We couldn’t find that page" %}
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
{% block title %}{% translate "Server error | " %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
<div class="grid-row grid-gap">
|
||||
<main id="main-content" class="grid-container grid-container--widescreen">
|
||||
<div class="grid-row grid-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
|
||||
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
|
||||
<h1>
|
||||
{% translate "We're having some trouble." %}
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
{% for model in app.models %}
|
||||
<tr class="model-{{ model.object_name|lower }}{% if model.admin_url in request.path|urlencode %} current-model{% endif %}">
|
||||
{% if model.admin_url %}
|
||||
<th scope="row"><a href="{{ model.admin_url }}"{% if model.admin_url in request.path|urlencode %} aria-current="page"{% endif %}>{{ model.name }}</a></th>
|
||||
<th scope="row"><a href="{{ model.admin_url }}"{% if model.admin_url in request.path|urlencode %} aria-current="page"{% endif %}">{{ model.name }}</a></th>
|
||||
{% else %}
|
||||
<th scope="row">{{ model.name }}</th>
|
||||
{% endif %}
|
||||
|
|
|
@ -61,7 +61,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
|
|||
{% if field.field.help_text %}
|
||||
{# .gov override #}
|
||||
{% block help_text %}
|
||||
<div class="help"{% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}>
|
||||
<div class="help"{% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}">
|
||||
<div>{{ field.field.help_text|safe }}</div>
|
||||
</div>
|
||||
{% endblock help_text %}
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
<select name="selected_user" id="selected_user" class="admin-combobox margin-top-0" onchange="this.form.submit()">
|
||||
<option value="">Select a user</option>
|
||||
{% for user in other_users %}
|
||||
<option value="{{ user.pk }}" {% if selected_user and user.pk == selected_user.pk %}selected{% endif %}>
|
||||
<option value="{{ user.pk }}" {% if selected_user and user.pk == selected_user.pk %}selected{% endif %}">
|
||||
{{ user.first_name }} {{ user.last_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
|
@ -154,7 +154,7 @@
|
|||
<dd>{{ current_user.email }}</dd>
|
||||
<dt>Phone:</dt>
|
||||
<dd>{{ current_user.phone }}</dd>
|
||||
<h3 class="font-heading-md" aria-label="Data that will added to:"> </h3>
|
||||
<h3 class="font-heading-md" aria-label="Data that will be added to:"> </h3>
|
||||
<dt>Domains:</dt>
|
||||
<dd>
|
||||
{% if current_user_domains %}
|
||||
|
|
|
@ -97,7 +97,7 @@
|
|||
<section class="usa-banner" aria-label="Official website of the United States government">
|
||||
<div class="usa-accordion">
|
||||
<header class="usa-banner__header">
|
||||
<div class="usa-banner__inner {% if is_widescreen_mode %} usa-banner__inner--widescreen {% endif %}">
|
||||
<div class="usa-banner__inner usa-banner__inner--widescreen padding-x--widescreen">
|
||||
<div class="grid-col-auto">
|
||||
<img class="usa-banner__header-flag" src="{% static 'img/us_flag_small.png' %}" alt="U.S. flag" />
|
||||
</div>
|
||||
|
@ -113,7 +113,7 @@
|
|||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="usa-banner__content usa-accordion__content" id="gov-banner-default">
|
||||
<div class="usa-banner__content usa-accordion__content padding-x--widescreen margin-x-0" id="gov-banner-default">
|
||||
<div class="grid-row grid-gap-lg">
|
||||
<div class="usa-banner__guidance tablet:grid-col-6">
|
||||
<img class="usa-banner__icon usa-media-block__img" src="{% static 'img/icon-dot-gov.svg' %}" role="img"
|
||||
|
@ -159,14 +159,14 @@
|
|||
|
||||
{% block wrapper %}
|
||||
{% block wrapperdiv %}
|
||||
<div id="wrapper">
|
||||
<div id="wrapper" class="wrapper--padding-top-6">
|
||||
{% endblock wrapperdiv %}
|
||||
{% block messages %}
|
||||
{% if messages %}
|
||||
<ul class="messages">
|
||||
{% for message in messages %}
|
||||
{% if 'base' in message.extra_tags %}
|
||||
<li{% if message.tags %} class="{{ message.tags }}" {% endif %}>
|
||||
<li{% if message.tags %} class="{{ message.tags }}" {% endif %}">
|
||||
{{ message }}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
{% if messages %}
|
||||
<ul class="messages">
|
||||
{% for message in messages %}
|
||||
<li {% if message.tags %} class="{{ message.tags }}" {% endif %}>
|
||||
<li {% if message.tags %} class="{{ message.tags }}" {% endif %}">
|
||||
{{ message }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
aria-labelledby="summary-box-description"
|
||||
>
|
||||
<div class="usa-summary-box__body">
|
||||
<h3 class="usa-summary-box__heading usa-summary-box__dhs-color" id="summary-box-description">
|
||||
<h3 class="usa-summary-box__heading" id="summary-box-description">
|
||||
When a domain is deleted:
|
||||
</h3>
|
||||
<div class="usa-summary-box__text">
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
aria-labelledby="summary-box-description"
|
||||
>
|
||||
<div class="usa-summary-box__body">
|
||||
<h3 class="usa-summary-box__heading usa-summary-box__dhs-color" id="summary-box-description">
|
||||
<h3 class="usa-summary-box__heading">
|
||||
When a domain is deleted:
|
||||
</h3>
|
||||
<div class="usa-summary-box__text">
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
||||
{% load custom_filters %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% 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 add someone to a domain here, it will trigger emails to the invitee and all managers of the domain when you click "save." If you don't want to trigger those emails, use the <a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User domain roles permissions table</a> instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
|
@ -110,21 +110,37 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endcomment %}
|
||||
<div class="readonly">
|
||||
{% with total_websites=field.contents|split:", " %}
|
||||
{% if total_websites|length == 1 %}
|
||||
<p class="margin-y-0 padding-y-0">
|
||||
<a href="{{ total_websites.0 }}" target="_blank">
|
||||
{{ total_websites.0 }}
|
||||
</a>
|
||||
</p>
|
||||
{% elif total_websites|length > 1 %}
|
||||
<ul class="margin-top-0 margin-left-0 padding-left-0{% if total_websites|length > 5 %} admin-list-inline{% endif %}">
|
||||
{% for website in total_websites %}
|
||||
<a href="{{ website }}" target="_blank" class="padding-top-1 current-website__{{forloop.counter}}">{{ website }}</a>{% if not forloop.last %}, {% endif %}
|
||||
{# Acts as a <br> #}
|
||||
{% if total_websites|length < 5 %}
|
||||
<div class="display-block margin-top-1"></div>
|
||||
{% endif %}
|
||||
{% comment %}White space matters: do NOT reformat the following line{% endcomment %}
|
||||
<li><a href="{{ website }}" target="_blank">{{ website }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% elif field.field.name == "alternative_domains" %}
|
||||
<div class="readonly">
|
||||
{% with current_path=request.get_full_path %}
|
||||
{% if original_object.alternative_domains.all|length == 1 %}
|
||||
<p class="margin-y-0 padding-y-0">
|
||||
<a href="{% url 'admin:registrar_website_change' original_object.alternative_domains.all.0.id %}?{{ 'return_path='|add:current_path }}" target="_blank">{{ original_object.alternative_domains.all.0 }}</a>
|
||||
</p>
|
||||
{% elif original_object.alternative_domains.all|length > 1 %}
|
||||
<ul class="margin-top-0 margin-left-0 padding-left-0 admin-list-inline">
|
||||
{% for alt_domain in original_object.alternative_domains.all %}
|
||||
<a href="{% url 'admin:registrar_website_change' alt_domain.id %}?{{ 'return_path='|add:current_path }}">{{ alt_domain }}</a>{% if not forloop.last %}, {% endif %}
|
||||
{% comment %}White space matters: do NOT reformat the following line{% endcomment %}
|
||||
<li><a href="{% url 'admin:registrar_website_change' alt_domain.id %}?{{ 'return_path='|add:current_path }}" target="_blank">{{alt_domain}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% elif field.field.name == "domain_managers" or field.field.name == "invited_domain_managers" %}
|
||||
|
@ -321,6 +337,22 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% else %}
|
||||
<input id="last-sent-rejection-email-content" class="display-none" value="None">
|
||||
{% endif %}
|
||||
{% elif field.field.name == "requested_suborganization" %}
|
||||
{{ field.field }}
|
||||
<div class="requested-suborganization--clear-button">
|
||||
<button
|
||||
id="clear-requested-suborganization"
|
||||
class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="usa-icon"
|
||||
>
|
||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Clear requested suborganization
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ field.field }}
|
||||
{% endif %}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
|
||||
{% comment %} This view provides a detail button that can be used to show/hide content {% endcomment %}
|
||||
<details class="margin-top-1 dja-detail-table" aria-role="button" {% if start_open %}open{% else %}closed{% endif %}>
|
||||
<details class="margin-top-1 dja-detail-table" aria-role="button" {% if start_open %}open{% else %}closed{% endif %}">
|
||||
<summary class="padding-1 padding-left-0 dja-details-summary">Details</summary>
|
||||
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||
{% block detail_content %}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block help_text %}
|
||||
<div class="help margin-bottom-1" {% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}>
|
||||
<div class="help margin-bottom-1" {% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}">
|
||||
{% if field.field.name == "state" %}
|
||||
<div>{{ state_help_message }}</div>
|
||||
{% else %}
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
<td>{{ member.user.phone }}</td>
|
||||
<td>
|
||||
{% for role in member.user|portfolio_role_summary:original %}
|
||||
<span class="usa-tag">{{ role }}</span>
|
||||
<span class="usa-tag bg-primary-dark text-semibold">{{ role }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="padding-left-1 text-size-small">
|
||||
|
|
|
@ -6,14 +6,14 @@
|
|||
<ul class="mulitple-choice">
|
||||
{% for choice in choices %}
|
||||
{% if choice.reset %}
|
||||
<li{% if choice.selected %} class="selected"{% endif %}>
|
||||
<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 %}
|
||||
{% if not choice.reset %}
|
||||
<li{% if choice.selected %} class="selected"{% endif %}>
|
||||
<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 }}
|
||||
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
||||
{% load custom_filters %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% 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 add someone to a portfolio here, it will trigger an invitation email when you click "save." If you don't want to trigger an email, use the <a class="usa-link" href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">User portfolio permissions table</a> instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
|
@ -0,0 +1,14 @@
|
|||
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
||||
{% load custom_filters %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% 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 add someone to a domain here, it will not trigger any emails. To trigger emails, use the <a class="usa-link" href="{% url 'admin:registrar_domaininvitation_changelist' %}">User Domain Role invitations table</a> instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
|
@ -2,15 +2,13 @@
|
|||
{% load custom_filters %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block field_sets %}
|
||||
{% for fieldset in adminform %}
|
||||
{% comment %}
|
||||
This is a placeholder for now.
|
||||
|
||||
Disclaimer:
|
||||
When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
|
||||
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
|
||||
{% endcomment %}
|
||||
{% include "django/admin/includes/user_portfolio_permission_fieldset.html" with original_object=original %}
|
||||
{% endfor %}
|
||||
{% 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 add someone to a portfolio here, it will not trigger an invitation email. To trigger an email, use the <a class="usa-link" href="{% url 'admin:registrar_portfolioinvitation_changelist' %}">Portfolio invitations table</a> instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
|
@ -2,15 +2,25 @@
|
|||
class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}"
|
||||
{% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %}
|
||||
>
|
||||
{% if legend_heading %}
|
||||
<h2 class="{{ legend_classes }}">{{ legend_heading }} </h2>
|
||||
{% if widget.attrs.id == 'id_additional_details-has_cisa_representative' %}
|
||||
<p>.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has <a href="https://www.cisa.gov/about/regions" target="_blank">10 regions</a> that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if span_for_text %}
|
||||
<span>{{ field.label }}</span>
|
||||
{% else %}
|
||||
{{ field.label }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if widget.attrs.required %}
|
||||
|
||||
{% if field.widget_type == 'radioselect' %}
|
||||
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
|
||||
<!--Don't add asterisk to one-field forms -->
|
||||
{% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %}
|
||||
{% elif field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." or field.label == "Has other contacts" %}
|
||||
{% else %}
|
||||
<abbr class="usa-hint usa-hint--required" title="required">*</abbr>
|
||||
{% endif %}
|
||||
|
|
|
@ -11,6 +11,7 @@ for now we just carry the attribute to both the parent element and the select.
|
|||
{{ name }}="{{ value }}"
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
data-default-value="{% for group_name, group_choices, group_index in widget.optgroups %}{% for option in group_choices %}{% if option.selected %}{{ option.value }}{% endif %}{% endfor %}{% endfor %}"
|
||||
>
|
||||
{% include "django/forms/widgets/select.html" %}
|
||||
{% include "django/forms/widgets/select.html" with is_combobox=True %}
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
{% load static custom_filters %}
|
||||
|
||||
<div class="{{ uswds_input_class }}">
|
||||
{% for group, options, index in widget.optgroups %}
|
||||
{% if group %}<div><label>{{ group }}</label>{% endif %}
|
||||
|
@ -13,7 +15,17 @@
|
|||
<label
|
||||
class="{{ uswds_input_class }}__label{% if label_classes %} {{ label_classes }}{% endif %}"
|
||||
for="{{ option.attrs.id }}"
|
||||
>{{ option.label }}</label>
|
||||
>
|
||||
{{ option.label }}
|
||||
{% comment %} Add a description on each, if available {% endcomment %}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
{% if group %}</div>{% endif %}
|
||||
{% endfor %}
|
||||
|
|
|
@ -3,6 +3,9 @@
|
|||
{# hint: spacing in the class string matters #}
|
||||
class="usa-select{% if classes %} {{ classes }}{% endif %}"
|
||||
{% include "django/forms/widgets/attrs.html" %}
|
||||
{% if is_combobox %}
|
||||
data-default-value="{% for group_name, group_choices, group_index in widget.optgroups %}{% for option in group_choices %}{% if option.selected %}{{ option.value }}{% endif %}{% endfor %}{% endfor %}"
|
||||
{% endif %}
|
||||
>
|
||||
{% for group, options, index in widget.optgroups %}
|
||||
{% if group %}<optgroup label="{{ group }}">{% endif %}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue