Merge remote-tracking branch 'origin' into ms/2823-update-delete-domain-process

This commit is contained in:
Matthew Spence 2024-12-13 16:37:26 -06:00
commit f2d7be10b4
No known key found for this signature in database
61 changed files with 2291 additions and 403 deletions

View file

@ -1,18 +1,18 @@
name: Issue name: Issue / story
description: Describe an idea, feature, content, or non-bug finding description: Describe an idea, problem, feature, or story. (Report bugs in the Bug template.)
body: body:
- type: markdown - type: markdown
id: title-help id: title-help
attributes: attributes:
value: | value: |
> Titles should be short, descriptive, and compelling. Use sentence case. > Titles should be short, descriptive, and compelling. Use sentence case: don't capitalize words unnecessarily.
- type: textarea - type: textarea
id: issue-description id: issue-description
attributes: attributes:
label: Issue description label: Issue description
description: | description: |
Describe the issue so that someone who wasn't present for its discovery can understand why it matters. Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax). Describe the issue so that someone who wasn't present for its discovery can understand why it matters. For stories, use the user story format (e.g., As a user, I want, so that). Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax).
validations: validations:
required: true required: true
- type: textarea - type: textarea
@ -31,7 +31,7 @@ body:
attributes: attributes:
label: Links to other issues label: Links to other issues
description: | description: |
"With a `-` to start the line, add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to)." "Use a dash (`-`) to start the line. Add an issue by typing "`#`" then the issue number. Add information to describe any dependancies, blockers, etc. (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to). If this is a parent issue, use sub-issues instead of linking other issues here."
placeholder: "- 🔄 Relates to..." placeholder: "- 🔄 Relates to..."
- type: markdown - type: markdown
id: note id: note

36
.github/ISSUE_TEMPLATE/sub-issue.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Sub-issue
description: Describe an idea, problem, or feature that is related to a parent issue.
body:
- type: markdown
id: title-help
attributes:
value: |
> Titles should be short, descriptive, and compelling. Use sentence case (don't capitalize unnecessarily).
- type: textarea
id: description
attributes:
label: Sub-issue description
description: |
Describe the issue so that someone who wasn't present for its discovery can understand why it matters. Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax).
For stories, use the user story format (e.g., As a user, I want, So that).
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: Acceptance criteria
description: If known, share 1-3 statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate.
placeholder: "- [ ]"
- type: textarea
id: additional-context
attributes:
label: Additional context
description: "Share any other thoughts, like how this might be implemented or fixed. Screenshots and links to documents/discussions are welcome."
- type: markdown
id: note
attributes:
value: |
> We may edit the text in this issue to document our understanding and clarify the product work.

View file

@ -378,3 +378,18 @@ Then, copy the variables under the section labled `s3`.
## Request Flow FSM Diagram ## Request Flow FSM Diagram
The [.gov Domain Request & Domain Status Digram](https://miro.com/app/board/uXjVMuqbLOk=/?moveToWidget=3458764594819017396&cot=14) visualizes the domain request flow and resulting domain objects. The [.gov Domain Request & Domain Status Digram](https://miro.com/app/board/uXjVMuqbLOk=/?moveToWidget=3458764594819017396&cot=14) visualizes the domain request flow and resulting domain objects.
## Testing the prototype add DNS record feature (delete this after we are done testing!)
We are currently testing using cloudflare to add DNS records. Specifically, an A record. To use this, you will need to enable the
`prototype_dns_flag` waffle flag and navigate to `igorville.gov`, `dns.gov`, or `domainops.gov`. Click manage, then click DNS. From there, click the `Prototype DNS record creator` button.
Before we can send data to cloudflare, you will need these values in your .env file:
```
REGISTRY_TENANT_KEY = {tenant key}
REGISTRY_SERVICE_EMAIL = {An email address}
REGISTRY_TENANT_NAME = {Name of the bucket, i.e. "CISA" }
```
You can obtain these by following the steps outlined in the [dns hosting discovery doc](https://docs.google.com/document/d/1Yq5d2M3MgM2vPhUBZ0k5wOmCQst4vND9-2qEZ55-h-Y/edit?tab=t.0), BUT it is far easier to just get these from someone else. Reach out to Zander for this information if you do not have it.
Alternatively, if you are testing on a sandbox, you will need to add those to getgov-credentials.

View file

@ -16,6 +16,14 @@ We use [django-waffle](https://waffle.readthedocs.io/en/stable/) for our feature
4. (Important) Set the field `Everyone` to `Unknown`. This field overrides all other settings when set to anything else. 4. (Important) Set the field `Everyone` to `Unknown`. This field overrides all other settings when set to anything else.
5. Configure the settings as you see fit. 5. Configure the settings as you see fit.
## Enabling a feature flag with portfolio permissions
1. Go to file `context_processors.py`
2. Add feature flag name to the `porfolio_context` within the `portfolio_permissions` method.
3. For the conditional under `if portfolio`, add the feature flag name, and assign the appropiate permission that are in the `user.py` model.
#### Note:
- If your use case includes non org, you want to add a feature flag outside of it, you can just update the portfolio context outside of the if statement.
## Using feature flags as boolean values ## Using feature flags as boolean values
Waffle [provides a boolean](https://waffle.readthedocs.io/en/stable/usage/views.html) called `flag_is_active` that you can use as you otherwise would a boolean. This boolean requires a request object and the flag name. Waffle [provides a boolean](https://waffle.readthedocs.io/en/stable/usage/views.html) called `flag_is_active` that you can use as you otherwise would a boolean. This boolean requires a request object and the flag name.

View file

@ -0,0 +1,73 @@
# HOWTO Add secrets to an existing sandbox
### Check if you need to add secrets
Run this command to get the environment variables from a sandbox:
```sh
cf env <APP>
```
For example `cf env getgov-development`
Check that these environment variables exist:
```
{
"DJANGO_SECRET_KEY": "EXAMPLE",
"DJANGO_SECRET_LOGIN_KEY": "EXAMPLE",
"AWS_ACCESS_KEY_ID": "EXAMPLE",
"AWS_SECRET_ACCESS_KEY": "EXAMPLE",
"REGISTRY_KEY": "EXAMPLE,
...
}
```
If those variable are not present, use the following steps to set secrets by creating a new `credentials-<ENVIRONMENT>.json` file and uploading it.
(Note that many of these commands were taken from the [`create_dev_sandbox.sh`](../../../ops/scripts/create_dev_sandbox.sh) script and were tested on MacOS)
### Create a new Django key
```sh
django_key=$(python3 -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())')
```
### Replace the existing certificate
Create a certificate:
```sh
openssl req -nodes -x509 -days 365 -newkey rsa:2048 -keyout private-<ENVIRONMENT>.pem -out public-<ENVIRONMENT>.crt
```
Fill in the following for the prompts:
Note: for "Common Name" you should put the name of the sandbox and for "Email Address" it should be the address of who owns that sandbox (such as the developer's email, if it's a developer sandbox, or whoever ran this action otherwise)
```sh
Country Name (2 letter code) [AU]: US
State or Province Name (full name) [Some-State]: DC
Locality Name (eg, city) []: DC
Organization Name (eg, company) [Internet Widgits Pty Ltd]: DHS
Organizational Unit Name (eg, section) []: CISA
Common Name (e.g. server FQDN or YOUR name) []: <ENVIRONMENT>
Email Address []: <example@something.com>
```
Go to https://dashboard.int.identitysandbox.gov/service_providers/2640/edit to remove the old certificate and upload the new one.
### Create the login key
```sh
login_key=$(base64 -i private-<ENVIRONMENT>.pem)
```
### Create the credentials file
```sh
jq -n --arg django_key "$django_key" --arg login_key "$login_key" '{"DJANGO_SECRET_KEY":$django_key,"DJANGO_SECRET_LOGIN_KEY":$login_key}' > credentials-<ENVIRONMENT>.json
```
Copy `REGISTRY_*` credentials from another sandbox into your `credentials-<ENVIRONMENT>.json` file. Also add your `AWS_*` credentials if you have them, otherwise also copy them from another sandbox. You can either use the cloud.gov dashboard or the command `cf env <APP>` to find other credentials.
### Update the `getgov-credentials` service tied to your environment.
```sh
cf uups getgov-credentials -p credentials-<ENVIRONMENT>.json
```
### Restage your application
```sh
cf restage getgov-<ENVIRONMENT> --strategy rolling
```

View file

@ -136,6 +136,7 @@ then
fi fi
cf service-key github-cd-account github-cd-key | sed 1,2d | jq -r '[.username, .password]|@tsv' | cf service-key github-cd-account github-cd-key | sed 1,2d | jq -r '[.username, .password]|@tsv' |
while read -r username password; do while read -r username password; do
gh secret --repo cisagov/getgov set CF_${upcase_name}_USERNAME --body $username gh secret --repo cisagov/getgov set CF_${upcase_name}_USERNAME --body $username
gh secret --repo cisagov/getgov set CF_${upcase_name}_PASSWORD --body $password gh secret --repo cisagov/getgov set CF_${upcase_name}_PASSWORD --body $password

View file

@ -59,6 +59,9 @@ services:
- AWS_S3_BUCKET_NAME - AWS_S3_BUCKET_NAME
# File encryption credentials # File encryption credentials
- SECRET_ENCRYPT_METADATA - SECRET_ENCRYPT_METADATA
- REGISTRY_TENANT_KEY
- REGISTRY_SERVICE_EMAIL
- REGISTRY_TENANT_NAME
stdin_open: true stdin_open: true
tty: true tty: true
ports: ports:

View file

@ -3,7 +3,14 @@ import logging
import copy import copy
from typing import Optional from typing import Optional
from django import forms from django import forms
from django.db.models import Value, CharField, Q from django.db.models import (
Case,
CharField,
F,
Q,
Value,
When,
)
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from registrar.models.federal_agency import FederalAgency from registrar.models.federal_agency import FederalAgency
@ -1467,21 +1474,57 @@ class DomainInformationResource(resources.ModelResource):
class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Customize domain information admin class.""" """Customize domain information admin class."""
class GenericOrgFilter(admin.SimpleListFilter):
"""Custom Generic Organization filter that accomodates portfolio feature.
If we have a portfolio, use the portfolio's organization. If not, use the
organization in the Domain Information object."""
title = "generic organization"
parameter_name = "converted_generic_orgs"
def lookups(self, request, model_admin):
converted_generic_orgs = set()
# 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
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
return queryset.filter(
Q(portfolio__organization_type=self.value())
| Q(portfolio__isnull=True, generic_org_type=self.value())
)
return queryset
resource_classes = [DomainInformationResource] resource_classes = [DomainInformationResource]
form = DomainInformationAdminForm form = DomainInformationAdminForm
# Customize column header text
@admin.display(description=_("Generic Org Type"))
def converted_generic_org_type(self, obj):
return obj.converted_generic_org_type_display
# Columns # Columns
list_display = [ list_display = [
"domain", "domain",
"generic_org_type", "converted_generic_org_type",
"created_at", "created_at",
] ]
orderable_fk_fields = [("domain", "name")] orderable_fk_fields = [("domain", "name")]
# Filters # Filters
list_filter = ["generic_org_type"] list_filter = [GenericOrgFilter]
# Search # Search
search_fields = [ search_fields = [
@ -1661,24 +1704,23 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
converted_generic_orgs = set() converted_generic_orgs = set()
# Populate the set with tuples of (value, display value)
for domain_request in DomainRequest.objects.all(): for domain_request in DomainRequest.objects.all():
converted_generic_org = domain_request.converted_generic_org_type converted_generic_org = domain_request.converted_generic_org_type # Actual value
if converted_generic_org: converted_generic_org_display = domain_request.converted_generic_org_type_display # Display value
converted_generic_orgs.add(converted_generic_org)
return sorted((org, org) for org in converted_generic_orgs) 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 # Filter queryset
def queryset(self, request, queryset): def queryset(self, request, queryset):
if self.value(): # Check if a generic org is selected in the filter if self.value(): # Check if a generic org is selected in the filter
return queryset.filter( return queryset.filter(
# Filter based on the generic org value returned by converted_generic_org_type Q(portfolio__organization_type=self.value())
id__in=[ | Q(portfolio__isnull=True, generic_org_type=self.value())
domain_request.id
for domain_request in queryset
if domain_request.converted_generic_org_type
and domain_request.converted_generic_org_type == self.value()
]
) )
return queryset return queryset
@ -1693,24 +1735,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
converted_federal_types = set() converted_federal_types = set()
# Populate the set with tuples of (value, display value)
for domain_request in DomainRequest.objects.all(): for domain_request in DomainRequest.objects.all():
converted_federal_type = domain_request.converted_federal_type converted_federal_type = domain_request.converted_federal_type # Actual value
if converted_federal_type: converted_federal_type_display = domain_request.converted_federal_type_display # Display value
converted_federal_types.add(converted_federal_type)
return sorted((type, type) for type in converted_federal_types) if converted_federal_type:
converted_federal_types.add(
(converted_federal_type, converted_federal_type_display) # Value, Display
)
# Sort the set by display value
return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value
# Filter queryset # Filter queryset
def queryset(self, request, queryset): def queryset(self, request, queryset):
if self.value(): # Check if federal Type is selected in the filter if self.value(): # Check if a federal type is selected in the filter
return queryset.filter( return queryset.filter(
# Filter based on the federal type returned by converted_federal_type Q(portfolio__federal_agency__federal_type=self.value())
id__in=[ | Q(portfolio__isnull=True, federal_type=self.value())
domain_request.id
for domain_request in queryset
if domain_request.converted_federal_type
and domain_request.converted_federal_type == self.value()
]
) )
return queryset return queryset
@ -1776,7 +1819,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
@admin.display(description=_("Generic Org Type")) @admin.display(description=_("Generic Org Type"))
def converted_generic_org_type(self, obj): def converted_generic_org_type(self, obj):
return obj.converted_generic_org_type return obj.converted_generic_org_type_display
@admin.display(description=_("Organization Name")) @admin.display(description=_("Organization Name"))
def converted_organization_name(self, obj): def converted_organization_name(self, obj):
@ -1788,7 +1831,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
@admin.display(description=_("Federal Type")) @admin.display(description=_("Federal Type"))
def converted_federal_type(self, obj): def converted_federal_type(self, obj):
return obj.converted_federal_type return obj.converted_federal_type_display
@admin.display(description=_("City")) @admin.display(description=_("City"))
def converted_city(self, obj): def converted_city(self, obj):
@ -2679,6 +2722,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
resource_classes = [DomainResource] resource_classes = [DomainResource]
# ------- FILTERS
class ElectionOfficeFilter(admin.SimpleListFilter): class ElectionOfficeFilter(admin.SimpleListFilter):
"""Define a custom filter for is_election_board""" """Define a custom filter for is_election_board"""
@ -2697,18 +2741,135 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if self.value() == "0": if self.value() == "0":
return queryset.filter(Q(domain_info__is_election_board=False) | Q(domain_info__is_election_board=None)) return queryset.filter(Q(domain_info__is_election_board=False) | Q(domain_info__is_election_board=None))
class GenericOrgFilter(admin.SimpleListFilter):
"""Custom Generic Organization filter that accomodates portfolio feature.
If we have a portfolio, use the portfolio's organization. If not, use the
organization in the Domain Information object."""
title = "generic organization"
parameter_name = "converted_generic_orgs"
def lookups(self, request, model_admin):
converted_generic_orgs = set()
# 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
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
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."""
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
)
# Sort the set by display value
return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value
# Filter queryset
def queryset(self, request, queryset):
if self.value(): # Check if a federal type is selected in the filter
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())
)
return queryset
def get_annotated_queryset(self, queryset):
return queryset.annotate(
converted_generic_org_type=Case(
# When portfolio is present, use its value instead
When(domain_info__portfolio__isnull=False, then=F("domain_info__portfolio__organization_type")),
# Otherwise, return the natively assigned value
default=F("domain_info__generic_org_type"),
),
converted_federal_agency=Case(
# When portfolio is present, use its value instead
When(
Q(domain_info__portfolio__isnull=False) & Q(domain_info__portfolio__federal_agency__isnull=False),
then=F("domain_info__portfolio__federal_agency__agency"),
),
# Otherwise, return the natively assigned value
default=F("domain_info__federal_agency__agency"),
),
converted_federal_type=Case(
# When portfolio is present, use its value instead
When(
Q(domain_info__portfolio__isnull=False) & Q(domain_info__portfolio__federal_agency__isnull=False),
then=F("domain_info__portfolio__federal_agency__federal_type"),
),
# Otherwise, return the natively assigned value
default=F("domain_info__federal_agency__federal_type"),
),
converted_organization_name=Case(
# When portfolio is present, use its value instead
When(domain_info__portfolio__isnull=False, then=F("domain_info__portfolio__organization_name")),
# Otherwise, return the natively assigned value
default=F("domain_info__organization_name"),
),
converted_city=Case(
# When portfolio is present, use its value instead
When(domain_info__portfolio__isnull=False, then=F("domain_info__portfolio__city")),
# Otherwise, return the natively assigned value
default=F("domain_info__city"),
),
converted_state_territory=Case(
# When portfolio is present, use its value instead
When(domain_info__portfolio__isnull=False, then=F("domain_info__portfolio__state_territory")),
# Otherwise, return the natively assigned value
default=F("domain_info__state_territory"),
),
)
# Filters
list_filter = [GenericOrgFilter, FederalTypeFilter, ElectionOfficeFilter, "state"]
# ------- END FILTERS
# Inlines
inlines = [DomainInformationInline] inlines = [DomainInformationInline]
# Columns # Columns
list_display = [ list_display = [
"name", "name",
"generic_org_type", "converted_generic_org_type",
"federal_type", "converted_federal_type",
"federal_agency", "converted_federal_agency",
"organization_name", "converted_organization_name",
"custom_election_board", "custom_election_board",
"city", "converted_city",
"state_territory", "converted_state_territory",
"state", "state",
"expiration_date", "expiration_date",
"created_at", "created_at",
@ -2723,28 +2884,81 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
), ),
) )
# ------- Domain Information Fields
# --- Generic Org Type
# Use converted value in the table
@admin.display(description=_("Generic Org Type"))
def converted_generic_org_type(self, obj):
return obj.domain_info.converted_generic_org_type_display
converted_generic_org_type.admin_order_field = "converted_generic_org_type" # type: ignore
# Use native value for the change form
def generic_org_type(self, obj): def generic_org_type(self, obj):
return obj.domain_info.get_generic_org_type_display() return obj.domain_info.get_generic_org_type_display()
generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore # --- Federal Agency
@admin.display(description=_("Federal Agency"))
def converted_federal_agency(self, obj):
return obj.domain_info.converted_federal_agency
converted_federal_agency.admin_order_field = "converted_federal_agency" # type: ignore
# Use native value for the change form
def federal_agency(self, obj): def federal_agency(self, obj):
if obj.domain_info: if obj.domain_info:
return obj.domain_info.federal_agency return obj.domain_info.federal_agency
else: else:
return None return None
federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore # --- Federal Type
# Use converted value in the table
@admin.display(description=_("Federal Type"))
def converted_federal_type(self, obj):
return obj.domain_info.converted_federal_type_display
converted_federal_type.admin_order_field = "converted_federal_type" # type: ignore
# Use native value for the change form
def federal_type(self, obj): def federal_type(self, obj):
return obj.domain_info.federal_type if obj.domain_info else None return obj.domain_info.federal_type if obj.domain_info else None
federal_type.admin_order_field = "domain_info__federal_type" # type: ignore # --- Organization Name
# Use converted value in the table
@admin.display(description=_("Organization Name"))
def converted_organization_name(self, obj):
return obj.domain_info.converted_organization_name
converted_organization_name.admin_order_field = "converted_organization_name" # type: ignore
# Use native value for the change form
def organization_name(self, obj): def organization_name(self, obj):
return obj.domain_info.organization_name if obj.domain_info else None return obj.domain_info.organization_name if obj.domain_info else None
organization_name.admin_order_field = "domain_info__organization_name" # type: ignore # --- City
# Use converted value in the table
@admin.display(description=_("City"))
def converted_city(self, obj):
return obj.domain_info.converted_city
converted_city.admin_order_field = "converted_city" # type: ignore
# Use native value for the change form
def city(self, obj):
return obj.domain_info.city if obj.domain_info else None
# --- State
# Use converted value in the table
@admin.display(description=_("State / territory"))
def converted_state_territory(self, obj):
return obj.domain_info.converted_state_territory
converted_state_territory.admin_order_field = "converted_state_territory" # type: ignore
# Use native value for the change form
def state_territory(self, obj):
return obj.domain_info.state_territory if obj.domain_info else None
def dnssecdata(self, obj): def dnssecdata(self, obj):
return "Yes" if obj.dnssecdata else "No" return "Yes" if obj.dnssecdata else "No"
@ -2777,23 +2991,14 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
custom_election_board.admin_order_field = "domain_info__is_election_board" # type: ignore custom_election_board.admin_order_field = "domain_info__is_election_board" # type: ignore
custom_election_board.short_description = "Election office" # type: ignore custom_election_board.short_description = "Election office" # type: ignore
def city(self, obj): # Search
return obj.domain_info.city if obj.domain_info else None
city.admin_order_field = "domain_info__city" # type: ignore
@admin.display(description=_("State / territory"))
def state_territory(self, obj):
return obj.domain_info.state_territory if obj.domain_info else None
state_territory.admin_order_field = "domain_info__state_territory" # type: ignore
# Filters
list_filter = ["domain_info__generic_org_type", "domain_info__federal_type", ElectionOfficeFilter, "state"]
search_fields = ["name"] search_fields = ["name"]
search_help_text = "Search by domain name." search_help_text = "Search by domain name."
# Change Form
change_form_template = "django/admin/domain_change_form.html" change_form_template = "django/admin/domain_change_form.html"
# Readonly Fields
readonly_fields = ( readonly_fields = (
"state", "state",
"expiration_date", "expiration_date",
@ -3058,7 +3263,8 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
def get_queryset(self, request): def get_queryset(self, request):
"""Custom get_queryset to filter by portfolio if portfolio is in the """Custom get_queryset to filter by portfolio if portfolio is in the
request params.""" request params."""
qs = super().get_queryset(request) initial_qs = super().get_queryset(request)
qs = self.get_annotated_queryset(initial_qs)
# Check if a 'portfolio' parameter is passed in the request # Check if a 'portfolio' parameter is passed in the request
portfolio_id = request.GET.get("portfolio") portfolio_id = request.GET.get("portfolio")
if portfolio_id: if portfolio_id:
@ -3579,6 +3785,14 @@ class WaffleFlagAdmin(FlagAdmin):
model = models.WaffleFlag model = models.WaffleFlag
fields = "__all__" fields = "__all__"
# Hack to get the dns_prototype_flag to auto populate when you navigate to
# the waffle flag page.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag")
return super().changelist_view(request, extra_context=extra_context)
class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin): class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["name", "portfolio"] list_display = ["name", "portfolio"]

View file

@ -15,8 +15,8 @@ function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, a
// Revert the dropdown to its previous value // Revert the dropdown to its previous value
statusDropdown.value = valueToCheck; statusDropdown.value = valueToCheck;
}); });
}else { } else {
console.log("displayModalOnDropdownClick() -> Cancel button was null"); console.warn("displayModalOnDropdownClick() -> Cancel button was null");
} }
// Add a change event listener to the dropdown. // Add a change event listener to the dropdown.

View file

@ -9,6 +9,7 @@ import { initDomainsTable } from './table-domains.js';
import { initDomainRequestsTable } from './table-domain-requests.js'; import { initDomainRequestsTable } from './table-domain-requests.js';
import { initMembersTable } from './table-members.js'; import { initMembersTable } from './table-members.js';
import { initMemberDomainsTable } from './table-member-domains.js'; import { initMemberDomainsTable } from './table-member-domains.js';
import { initEditMemberDomainsTable } from './table-edit-member-domains.js';
import { initPortfolioMemberPageToggle } from './portfolio-member-page.js'; import { initPortfolioMemberPageToggle } from './portfolio-member-page.js';
import { initAddNewMemberPageListeners } from './portfolio-member-page.js'; import { initAddNewMemberPageListeners } from './portfolio-member-page.js';
@ -41,6 +42,7 @@ initDomainsTable();
initDomainRequestsTable(); initDomainRequestsTable();
initMembersTable(); initMembersTable();
initMemberDomainsTable(); initMemberDomainsTable();
initEditMemberDomainsTable();
initPortfolioMemberPageToggle(); initPortfolioMemberPageToggle();
initAddNewMemberPageListeners(); initAddNewMemberPageListeners();

View file

@ -49,7 +49,7 @@ export function initPortfolioMemberPageToggle() {
* on the Add New Member page. * on the Add New Member page.
*/ */
export function initAddNewMemberPageListeners() { export function initAddNewMemberPageListeners() {
add_member_form = document.getElementById("add_member_form") let add_member_form = document.getElementById("add_member_form");
if (!add_member_form){ if (!add_member_form){
return; return;
} }

View file

@ -126,6 +126,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
export class BaseTable { export class BaseTable {
constructor(itemName) { constructor(itemName) {
this.itemName = itemName; this.itemName = itemName;
this.displayName = itemName;
this.sectionSelector = itemName + 's'; this.sectionSelector = itemName + 's';
this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`); this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`);
this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`); this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`);
@ -183,7 +184,7 @@ export class BaseTable {
// Counter should only be displayed if there is more than 1 item // Counter should only be displayed if there is more than 1 item
paginationSelectorEl.classList.toggle('display-none', totalItems < 1); paginationSelectorEl.classList.toggle('display-none', totalItems < 1);
counterSelectorEl.innerHTML = `${totalItems} ${this.itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; counterSelectorEl.innerHTML = `${totalItems} ${this.displayName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`;
// Helper function to create a pagination item // Helper function to create a pagination item
const createPaginationItem = (page) => { const createPaginationItem = (page) => {
@ -416,6 +417,11 @@ export class BaseTable {
*/ */
initShowMoreButtons(){} initShowMoreButtons(){}
/**
* See function for more details
*/
initCheckboxListeners(){}
/** /**
* Loads rows in the members list, as well as updates pagination around the members list * Loads rows in the members list, as well as updates pagination around the members list
* based on the supplied attributes. * based on the supplied attributes.
@ -431,7 +437,7 @@ export class BaseTable {
let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio);
// --------- FETCH DATA // --------- FETCH DATA
// fetch json of page of domains, given params // fetch json of page of objects, given params
const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null;
if (!baseUrlValue) return; if (!baseUrlValue) return;
@ -462,6 +468,7 @@ export class BaseTable {
}); });
this.initShowMoreButtons(); this.initShowMoreButtons();
this.initCheckboxListeners();
this.loadModals(data.page, data.total, data.unfiltered_total); this.loadModals(data.page, data.total, data.unfiltered_total);

View file

@ -23,6 +23,7 @@ export class DomainRequestsTable extends BaseTable {
constructor() { constructor() {
super('domain-request'); super('domain-request');
this.displayName = "domain request";
} }
getBaseUrl() { getBaseUrl() {

View file

@ -0,0 +1,234 @@
import { BaseTable } from './table-base.js';
/**
* EditMemberDomainsTable is used for PortfolioMember and PortfolioInvitedMember
* Domain Editing.
*
* This table has additional functionality for tracking and making changes
* to domains assigned to the member/invited member.
*/
export class EditMemberDomainsTable extends BaseTable {
constructor() {
super('edit-member-domain');
this.displayName = "domain";
this.currentSortBy = 'name';
this.initialDomainAssignments = []; // list of initially assigned domains
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.initializeDomainAssignments();
this.initCancelEditDomainAssignmentButton();
}
getBaseUrl() {
return document.getElementById("get_member_domains_json_url");
}
getDataObjects(data) {
return data.domains;
}
/** getDomainAssignmentSearchParams is used to prepare search to populate
* initialDomainAssignments and initialDomainAssignmentsOnlyMember
*
* searches with memberOnly True so that only domains assigned to the member are returned
*/
getDomainAssignmentSearchParams(portfolio) {
let searchParams = new URLSearchParams();
let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null;
let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null;
let memberOnly = true;
if (portfolio)
searchParams.append("portfolio", portfolio);
if (emailValue)
searchParams.append("email", emailValue);
if (memberIdValue)
searchParams.append("member_id", memberIdValue);
if (memberOnly)
searchParams.append("member_only", memberOnly);
return searchParams;
}
/** getSearchParams extends base class getSearchParams.
*
* additional searchParam for this table is checkedDomains. This is used to allow
* for backend sorting by domains which are 'checked' in the form.
*/
getSearchParams(page, sortBy, order, searchTerm, status, portfolio) {
let searchParams = super.getSearchParams(page, sortBy, order, searchTerm, status, portfolio);
// Add checkedDomains to searchParams
// Clone the initial domains to avoid mutating them
let checkedDomains = [...this.initialDomainAssignments];
// Add IDs from addedDomains that are not already in checkedDomains
this.addedDomains.forEach(domain => {
if (!checkedDomains.includes(domain.id)) {
checkedDomains.push(domain.id);
}
});
// Remove IDs from removedDomains
this.removedDomains.forEach(domain => {
const index = checkedDomains.indexOf(domain.id);
if (index !== -1) {
checkedDomains.splice(index, 1);
}
});
// Append updated checkedDomain IDs to searchParams
if (checkedDomains.length > 0) {
searchParams.append("checkedDomainIds", checkedDomains.join(","));
}
return searchParams;
}
addRow(dataObject, tbody, customTableOptions) {
const domain = dataObject;
const row = document.createElement('tr');
let checked = false;
let disabled = false;
if (
(this.initialDomainAssignments.includes(domain.id) ||
this.addedDomains.map(obj => obj.id).includes(domain.id)) &&
!this.removedDomains.map(obj => obj.id).includes(domain.id)
) {
checked = true;
}
if (this.initialDomainAssignmentsOnlyMember.includes(domain.id)) {
disabled = true;
}
row.innerHTML = `
<td data-label="Selection" data-sort-value="0" class="padding-right-105">
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="${domain.id}"
type="checkbox"
name="${domain.name}"
value="${domain.id}"
${checked ? 'checked' : ''}
${disabled ? 'disabled' : ''}
/>
<label class="usa-checkbox__label margin-top-0" for="${domain.id}">
<span class="sr-only">${domain.id}</span>
</label>
</div>
</td>
<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>' : ''}
</td>
`;
tbody.appendChild(row);
}
/**
* initializeDomainAssignments searches via ajax on page load for domains assigned to
* member. It populates both initialDomainAssignments and initialDomainAssignmentsOnlyMember.
* It is called once per page load, but not called with subsequent table changes.
*/
initializeDomainAssignments() {
const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null;
if (!baseUrlValue) return;
let searchParams = this.getDomainAssignmentSearchParams(this.portfolioValue);
let url = baseUrlValue + "?" + searchParams.toString();
fetch(url)
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('Error in AJAX call: ' + data.error);
return;
}
let dataObjects = this.getDataObjects(data);
// Map the id attributes of dataObjects to this.initialDomainAssignments
this.initialDomainAssignments = dataObjects.map(obj => obj.id);
this.initialDomainAssignmentsOnlyMember = dataObjects
.filter(obj => obj.member_is_only_manager)
.map(obj => obj.id);
})
.catch(error => console.error('Error fetching domain assignments:', error));
}
/**
* Initializes listeners on checkboxes in the table. Checkbox listeners are used
* in this case to track changes to domain assignments in js (addedDomains and removedDomains)
* before changes are saved.
* initCheckboxListeners is called each time table is loaded.
*/
initCheckboxListeners() {
const checkboxes = this.tableWrapper.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', () => {
const domain = { id: +checkbox.value, name: checkbox.name };
if (checkbox.checked) {
this.updateDomainLists(domain, this.removedDomains, this.addedDomains);
} else {
this.updateDomainLists(domain, this.addedDomains, this.removedDomains);
}
});
});
}
/**
* Helper function which updates domain lists. When called, if domain is in the fromList,
* it removes it; if domain is not in the toList, it is added to the toList.
* @param {*} domain - object containing the domain id and name
* @param {*} fromList - list of domains
* @param {*} toList - list of domains
*/
updateDomainLists(domain, fromList, toList) {
const index = fromList.findIndex(item => item.id === domain.id && item.name === domain.name);
if (index > -1) {
fromList.splice(index, 1); // Remove from the `fromList` if it exists
} else {
toList.push(domain); // Add to the `toList` if not already there
}
}
/**
* initializes the Cancel button on the Edit domains page.
* Cancel triggers modal in certain conditions and the initialization for the modal is done
* in this function.
*/
initCancelEditDomainAssignmentButton() {
const cancelEditDomainAssignmentButton = document.getElementById('cancel-edit-domain-assignments');
if (!cancelEditDomainAssignmentButton) {
console.error("Expected element #cancel-edit-domain-assignments, but it does not exist.");
return; // Exit early if the button doesn't exist
}
// Find the last breadcrumb link
const lastPageLinkElement = document.querySelector('.usa-breadcrumb__list-item:nth-last-child(2) a');
const lastPageLink = lastPageLinkElement ? lastPageLinkElement.getAttribute('href') : null;
const hiddenModalTrigger = document.getElementById("hidden-cancel-edit-domain-assignments-modal-trigger");
if (!lastPageLink) {
console.warn("Last breadcrumb link not found or missing href.");
}
if (!hiddenModalTrigger) {
console.warn("Hidden modal trigger not found.");
}
// Add click event listener
cancelEditDomainAssignmentButton.addEventListener('click', () => {
if (this.addedDomains.length || this.removedDomains.length) {
console.log('Changes detected. Triggering modal...');
hiddenModalTrigger.click();
} else if (lastPageLink) {
window.location.href = lastPageLink; // Redirect to the last breadcrumb link
} else {
console.warn("No changes detected, but no valid lastPageLink to navigate to.");
}
});
}
}
export function initEditMemberDomainsTable() {
document.addEventListener('DOMContentLoaded', function() {
const isEditMemberDomainsPage = document.getElementById("edit-member-domains");
if (isEditMemberDomainsPage) {
const editMemberDomainsTable = new EditMemberDomainsTable();
if (editMemberDomainsTable.tableWrapper) {
// Initial load
editMemberDomainsTable.loadTable(1);
}
}
});
}

View file

@ -5,6 +5,7 @@ export class MemberDomainsTable extends BaseTable {
constructor() { constructor() {
super('member-domain'); super('member-domain');
this.displayName = "domain";
this.currentSortBy = 'name'; this.currentSortBy = 'name';
} }
getBaseUrl() { getBaseUrl() {

View file

@ -73,11 +73,15 @@ th {
} }
} }
td, th, td, th {
.usa-tabel th{
padding: units(2) units(4) units(2) 0; padding: units(2) units(4) units(2) 0;
} }
// Hack fix to the overly specific selector above that broke utility class usefulness
.padding-right-105 {
padding-right: .75rem;
}
thead tr:first-child th:first-child { thead tr:first-child th:first-child {
border-top: none; border-top: none;
} }

View file

@ -86,6 +86,11 @@ secret_registry_key = b64decode(secret("REGISTRY_KEY", ""))
secret_registry_key_passphrase = secret("REGISTRY_KEY_PASSPHRASE", "") secret_registry_key_passphrase = secret("REGISTRY_KEY_PASSPHRASE", "")
secret_registry_hostname = secret("REGISTRY_HOSTNAME") secret_registry_hostname = secret("REGISTRY_HOSTNAME")
# PROTOTYPE: Used for DNS hosting
secret_registry_tenant_key = secret("REGISTRY_TENANT_KEY", None)
secret_registry_tenant_name = secret("REGISTRY_TENANT_NAME", None)
secret_registry_service_email = secret("REGISTRY_SERVICE_EMAIL", None)
# region: Basic Django Config-----------------------------------------------### # region: Basic Django Config-----------------------------------------------###
# Build paths inside the project like this: BASE_DIR / "subdir". # Build paths inside the project like this: BASE_DIR / "subdir".
@ -685,6 +690,9 @@ SECRET_REGISTRY_CERT = secret_registry_cert
SECRET_REGISTRY_KEY = secret_registry_key SECRET_REGISTRY_KEY = secret_registry_key
SECRET_REGISTRY_KEY_PASSPHRASE = secret_registry_key_passphrase SECRET_REGISTRY_KEY_PASSPHRASE = secret_registry_key_passphrase
SECRET_REGISTRY_HOSTNAME = secret_registry_hostname SECRET_REGISTRY_HOSTNAME = secret_registry_hostname
SECRET_REGISTRY_TENANT_KEY = secret_registry_tenant_key
SECRET_REGISTRY_TENANT_NAME = secret_registry_tenant_name
SECRET_REGISTRY_SERVICE_EMAIL = secret_registry_service_email
# endregion # endregion
# region: Security and Privacy----------------------------------------------### # region: Security and Privacy----------------------------------------------###
@ -816,7 +824,9 @@ SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
# session engine to cache session information # session engine to cache session information
SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_ENGINE = "django.contrib.sessions.backends.db"
SESSION_SERIALIZER = "django.contrib.sessions.serializers.PickleSerializer"
# ~ Set by django.middleware.clickjacking.XFrameOptionsMiddleware # ~ Set by django.middleware.clickjacking.XFrameOptionsMiddleware
# prevent clickjacking by instructing the browser not to load # prevent clickjacking by instructing the browser not to load

View file

@ -46,8 +46,8 @@ DOMAIN_REQUEST_NAMESPACE = views.DomainRequestWizard.URL_NAMESPACE
# dynamically generate the other domain_request_urls # dynamically generate the other domain_request_urls
domain_request_urls = [ domain_request_urls = [
path("", RedirectView.as_view(pattern_name="domain-request:start"), name="redirect-to-start"), path("", RedirectView.as_view(pattern_name="domain-request:start"), name="redirect-to-start"),
path("start/", views.DomainRequestWizard.as_view(), name="start"), path("start/", views.DomainRequestWizard.as_view(), name=views.DomainRequestWizard.NEW_URL_NAME),
path("finished/", views.Finished.as_view(), name="finished"), path("finished/", views.Finished.as_view(), name=views.DomainRequestWizard.FINISHED_URL_NAME),
] ]
for step, view in [ for step, view in [
# add/remove steps here # add/remove steps here
@ -109,6 +109,11 @@ urlpatterns = [
views.PortfolioMemberDomainsView.as_view(), views.PortfolioMemberDomainsView.as_view(),
name="member-domains", name="member-domains",
), ),
path(
"member/<int:pk>/domains/edit",
views.PortfolioMemberDomainsEditView.as_view(),
name="member-domains-edit",
),
path( path(
"invitedmember/<int:pk>", "invitedmember/<int:pk>",
views.PortfolioInvitedMemberView.as_view(), views.PortfolioInvitedMemberView.as_view(),
@ -129,6 +134,11 @@ urlpatterns = [
views.PortfolioInvitedMemberDomainsView.as_view(), views.PortfolioInvitedMemberDomainsView.as_view(),
name="invitedmember-domains", name="invitedmember-domains",
), ),
path(
"invitedmember/<int:pk>/domains/edit",
views.PortfolioInvitedMemberDomainsEditView.as_view(),
name="invitedmember-domains-edit",
),
# path( # path(
# "no-organization-members/", # "no-organization-members/",
# views.PortfolioNoMembersView.as_view(), # views.PortfolioNoMembersView.as_view(),
@ -255,11 +265,6 @@ urlpatterns = [
ExportDataTypeRequests.as_view(), ExportDataTypeRequests.as_view(),
name="export_data_type_requests", name="export_data_type_requests",
), ),
path(
"reports/export_data_type_requests/",
ExportDataTypeRequests.as_view(),
name="export_data_type_requests",
),
path( path(
"domain-request/<int:id>/edit/", "domain-request/<int:id>/edit/",
views.DomainRequestWizard.as_view(), views.DomainRequestWizard.as_view(),
@ -298,6 +303,7 @@ urlpatterns = [
name="todo", name="todo",
), ),
path("domain/<int:pk>", views.DomainView.as_view(), name="domain"), path("domain/<int:pk>", views.DomainView.as_view(), name="domain"),
path("domain/<int:pk>/prototype-dns", views.PrototypeDomainDNSRecordView.as_view(), name="prototype-domain-dns"),
path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"), path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path( path(
"domain/<int:pk>/dns", "domain/<int:pk>/dns",

View file

@ -99,7 +99,7 @@ def portfolio_permissions(request):
def is_widescreen_mode(request): def is_widescreen_mode(request):
widescreen_paths = [] widescreen_paths = [] # If this list is meant to include specific paths, populate it.
portfolio_widescreen_paths = [ portfolio_widescreen_paths = [
"/domains/", "/domains/",
"/requests/", "/requests/",
@ -108,10 +108,21 @@ def is_widescreen_mode(request):
"/no-organization-domains/", "/no-organization-domains/",
"/domain-request/", "/domain-request/",
] ]
# widescreen_paths can be a bear as it trickles down sub-urls. exclude_paths gives us a way out.
exclude_paths = [
"/domains/edit",
]
# 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_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/"
is_portfolio_widescreen = bool(
# Check if the user is an organization user and the path matches portfolio paths.
is_portfolio_widescreen = (
hasattr(request.user, "is_org_user") hasattr(request.user, "is_org_user")
and request.user.is_org_user(request) and request.user.is_org_user(request)
and any(path in request.path for path in portfolio_widescreen_paths) 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)
) )
# Return a dictionary with the widescreen mode status.
return {"is_widescreen_mode": is_widescreen or is_portfolio_widescreen} return {"is_widescreen_mode": is_widescreen or is_portfolio_widescreen}

View file

@ -527,7 +527,12 @@ class DotGovDomainForm(RegistrarForm):
class PurposeForm(RegistrarForm): class PurposeForm(RegistrarForm):
purpose = forms.CharField( purpose = forms.CharField(
label="Purpose", label="Purpose",
widget=forms.Textarea(), widget=forms.Textarea(
attrs={
"aria-label": "What is the purpose of your requested domain? Describe how youll use your .gov domain. \
Will it be used for a website, email, or something else? You can enter up to 2000 characters."
}
),
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(
2000, 2000,
@ -794,6 +799,22 @@ class AnythingElseForm(BaseDeletableRegistrarForm):
) )
class PortfolioAnythingElseForm(BaseDeletableRegistrarForm):
"""The form for the portfolio additional details page. Tied to the anything_else field."""
anything_else = forms.CharField(
required=False,
label="Anything else?",
widget=forms.Textarea(),
validators=[
MaxLengthValidator(
2000,
message="Response must be less than 2000 characters.",
)
],
)
class AnythingElseYesNoForm(BaseYesNoForm): class AnythingElseYesNoForm(BaseYesNoForm):
"""Yes/no toggle for the anything else question on additional details""" """Yes/no toggle for the anything else question on additional details"""

View file

@ -295,7 +295,6 @@ class Command(BaseCommand):
except Exception as err: except Exception as err:
logger.error(f"Could not load additional TransitionDomain data. {err}") logger.error(f"Could not load additional TransitionDomain data. {err}")
raise err raise err
# TODO: handle this better...needs more logging
def handle( # noqa: C901 def handle( # noqa: C901
self, self,

View file

@ -29,9 +29,6 @@ logger = logging.getLogger(__name__)
class Command(BaseCommand): class Command(BaseCommand):
help = """ """ # TODO: update this! help = """ """ # TODO: update this!
# ======================================================
# ================== ARGUMENTS ===================
# ======================================================
def add_arguments(self, parser): def add_arguments(self, parser):
""" """
OPTIONAL ARGUMENTS: OPTIONAL ARGUMENTS:

View file

@ -0,0 +1,28 @@
# Generated by Django 4.2.10 on 2024-11-27 21:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0138_alter_domaininvitation_status"),
]
operations = [
migrations.AlterField(
model_name="domainrequest",
name="action_needed_reason",
field=models.TextField(
blank=True,
choices=[
("eligibility_unclear", "Unclear organization eligibility"),
("questionable_senior_official", "Questionable senior official"),
("already_has_a_domain", "Already has a domain"),
("bad_name", "Doesnt meet naming requirements"),
("other", "Other (no auto-email sent)"),
],
null=True,
),
),
]

View file

@ -4,7 +4,6 @@ import ipaddress
import re import re
from datetime import date from datetime import date
from typing import Optional from typing import Optional
from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
from django.db import models from django.db import models

View file

@ -426,13 +426,14 @@ class DomainInformation(TimeStampedModel):
else: else:
return None return None
# ----- Portfolio Properties -----
@property @property
def converted_organization_name(self): def converted_organization_name(self):
if self.portfolio: if self.portfolio:
return self.portfolio.organization_name return self.portfolio.organization_name
return self.organization_name return self.organization_name
# ----- Portfolio Properties -----
@property @property
def converted_generic_org_type(self): def converted_generic_org_type(self):
if self.portfolio: if self.portfolio:
@ -454,20 +455,20 @@ class DomainInformation(TimeStampedModel):
@property @property
def converted_senior_official(self): def converted_senior_official(self):
if self.portfolio: if self.portfolio:
return self.portfolio.senior_official return self.portfolio.display_senior_official
return self.senior_official return self.display_senior_official
@property @property
def converted_address_line1(self): def converted_address_line1(self):
if self.portfolio: if self.portfolio:
return self.portfolio.address_line1 return self.portfolio.display_address_line1
return self.address_line1 return self.display_address_line1
@property @property
def converted_address_line2(self): def converted_address_line2(self):
if self.portfolio: if self.portfolio:
return self.portfolio.address_line2 return self.portfolio.display_address_line2
return self.address_line2 return self.display_address_line2
@property @property
def converted_city(self): def converted_city(self):
@ -478,17 +479,30 @@ class DomainInformation(TimeStampedModel):
@property @property
def converted_state_territory(self): def converted_state_territory(self):
if self.portfolio: if self.portfolio:
return self.portfolio.state_territory return self.portfolio.get_state_territory_display()
return self.state_territory return self.get_state_territory_display()
@property @property
def converted_zipcode(self): def converted_zipcode(self):
if self.portfolio: if self.portfolio:
return self.portfolio.zipcode return self.portfolio.display_zipcode
return self.zipcode return self.display_zipcode
@property @property
def converted_urbanization(self): def converted_urbanization(self):
if self.portfolio: if self.portfolio:
return self.portfolio.urbanization return self.portfolio.display_urbanization
return self.urbanization return self.display_urbanization
# ----- Portfolio Properties (display values)-----
@property
def converted_generic_org_type_display(self):
if self.portfolio:
return self.portfolio.get_organization_type_display()
return self.get_generic_org_type_display()
@property
def converted_federal_type_display(self):
if self.portfolio:
return self.portfolio.federal_agency.get_federal_type_display()
return self.get_federal_type_display()

View file

@ -280,7 +280,7 @@ class DomainRequest(TimeStampedModel):
ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility") ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility")
QUESTIONABLE_SENIOR_OFFICIAL = ("questionable_senior_official", "Questionable senior official") QUESTIONABLE_SENIOR_OFFICIAL = ("questionable_senior_official", "Questionable senior official")
ALREADY_HAS_DOMAINS = ("already_has_domains", "Already has domains") ALREADY_HAS_A_DOMAIN = ("already_has_a_domain", "Already has a domain")
BAD_NAME = ("bad_name", "Doesnt meet naming requirements") BAD_NAME = ("bad_name", "Doesnt meet naming requirements")
OTHER = ("other", "Other (no auto-email sent)") OTHER = ("other", "Other (no auto-email sent)")
@ -1437,6 +1437,18 @@ class DomainRequest(TimeStampedModel):
return self.portfolio.federal_type return self.portfolio.federal_type
return self.federal_type return self.federal_type
@property
def converted_address_line1(self):
if self.portfolio:
return self.portfolio.address_line1
return self.address_line1
@property
def converted_address_line2(self):
if self.portfolio:
return self.portfolio.address_line2
return self.address_line2
@property @property
def converted_city(self): def converted_city(self):
if self.portfolio: if self.portfolio:
@ -1449,8 +1461,33 @@ class DomainRequest(TimeStampedModel):
return self.portfolio.state_territory return self.portfolio.state_territory
return self.state_territory return self.state_territory
@property
def converted_urbanization(self):
if self.portfolio:
return self.portfolio.urbanization
return self.urbanization
@property
def converted_zipcode(self):
if self.portfolio:
return self.portfolio.zipcode
return self.zipcode
@property @property
def converted_senior_official(self): def converted_senior_official(self):
if self.portfolio: if self.portfolio:
return self.portfolio.senior_official return self.portfolio.senior_official
return self.senior_official return self.senior_official
# ----- Portfolio Properties (display values)-----
@property
def converted_generic_org_type_display(self):
if self.portfolio:
return self.portfolio.get_organization_type_display()
return self.get_generic_org_type_display()
@property
def converted_federal_type_display(self):
if self.portfolio:
return self.portfolio.federal_agency.get_federal_type_display()
return self.get_federal_type_display()

View file

@ -5,7 +5,7 @@
{% block title %}{% translate "Unauthorized | " %}{% endblock %} {% block title %}{% translate "Unauthorized | " %}{% endblock %}
{% block content %} {% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}"> <main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<div class="grid-row grow-gap"> <div class="grid-row grow-gap">
<div class="tablet:grid-col-6 usa-prose margin-bottom-3"> <div class="tablet:grid-col-6 usa-prose margin-bottom-3">
<h1> <h1>

View file

@ -5,7 +5,7 @@
{% block title %}{% translate "Forbidden | " %}{% endblock %} {% block title %}{% translate "Forbidden | " %}{% endblock %}
{% block content %} {% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}"> <main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<div class="grid-row grow-gap"> <div class="grid-row grow-gap">
<div class="tablet:grid-col-6 usa-prose margin-bottom-3"> <div class="tablet:grid-col-6 usa-prose margin-bottom-3">
<h1> <h1>

View file

@ -5,7 +5,7 @@
{% block title %}{% translate "Page not found | " %}{% endblock %} {% block title %}{% translate "Page not found | " %}{% endblock %}
{% block content %} {% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}"> <main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<div class="grid-row grid-gap"> <div class="grid-row grid-gap">
<div class="tablet:grid-col-6 usa-prose margin-bottom-3"> <div class="tablet:grid-col-6 usa-prose margin-bottom-3">
<h1> <h1>

View file

@ -5,7 +5,7 @@
{% block title %}{% translate "Server error | " %}{% endblock %} {% block title %}{% translate "Server error | " %}{% endblock %}
{% block content %} {% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}"> <main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<div class="grid-row grid-gap"> <div class="grid-row grid-gap">
<div class="tablet:grid-col-6 usa-prose margin-bottom-3"> <div class="tablet:grid-col-6 usa-prose margin-bottom-3">
<h1> <h1>

View file

@ -5,6 +5,5 @@
class="{{ uswds_input_class }}{% if classes %} {{ classes }}{% endif %}" class="{{ uswds_input_class }}{% if classes %} {{ classes }}{% endif %}"
{% if widget.value != None %}value="{{ widget.value|stringformat:'s' }}"{% endif %} {% if widget.value != None %}value="{{ widget.value|stringformat:'s' }}"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }} {{ label }}"{% endif %} {% if aria_label %}aria-label="{{ aria_label }} {{ label }}"{% endif %}
{% if sublabel_text %}aria-describedby="{{ widget.attrs.id }}__sublabel"{% endif %}
{% include "django/forms/widgets/attrs.html" %} {% include "django/forms/widgets/attrs.html" %}
/> />

View file

@ -29,12 +29,16 @@
<p>You can enter your name servers, as well as other DNS-related information, in the following sections:</p> <p>You can enter your name servers, as well as other DNS-related information, in the following sections:</p>
{% url 'domain-dns-nameservers' pk=domain.id as url %} {% url 'domain-dns-nameservers' pk=domain.id as url %}
<ul class="usa-list"> <ul class="usa-list">
<li><a href="{{ url }}">Name servers</a></li> <li><a href="{{ url }}">Name servers</a></li>
{% url 'domain-dns-dnssec' pk=domain.id as url %} {% url 'domain-dns-dnssec' pk=domain.id as url %}
<li><a href="{{ url }}">DNSSEC</a></li> <li><a href="{{ url }}">DNSSEC</a></li>
{% if dns_prototype_flag and is_valid_domain %}
<li><a href="{% url 'prototype-domain-dns' pk=domain.id %}">Prototype DNS record creator</a></li>
{% endif %}
</ul> </ul>
{% endblock %} {# domain_content #} {% endblock %} {# domain_content #}

View file

@ -5,7 +5,7 @@
{% block title %} Home | {% endblock %} {% block title %} Home | {% endblock %}
{% block content %} {% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}"> <main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
{% if user.is_authenticated %} {% if user.is_authenticated %}
{# the entire logged in page goes here #} {# the entire logged in page goes here #}

View file

@ -3,7 +3,7 @@
<footer class="usa-footer"> <footer class="usa-footer">
<div class="usa-footer__secondary-section"> <div class="usa-footer__secondary-section">
<div class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}"> <div class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<div class="grid-row grid-gap"> <div class="grid-row grid-gap">
<div <div
class=" class="

View file

@ -0,0 +1,142 @@
{% load static %}
{% if member %}
<span
id="portfolio-js-value"
class="display-none"
data-portfolio="{{ portfolio.id }}"
data-email=""
data-member-id="{{ member.id }}"
data-member-only="false"
></span>
{% else %}
<span
id="portfolio-js-value"
class="display-none"
data-portfolio="{{ portfolio.id }}"
data-email="{{ portfolio_invitation.email }}"
data-member-id=""
data-member-only="false"
></span>
{% endif %}
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_member_domains_json' as url %}
<span id="get_member_domains_json_url" class="display-none">{{url}}</span>
<section class="section-outlined member-domains margin-top-0 section-outlined--border-base-light" id="edit-member-domains">
<h2>
Edit domains assigned to
{% if member %}
{{ member.email }}
{% else %}
{{ portfolio_invitation.email }}
{% endif %}
</h2>
<div class="section-outlined__header margin-bottom-3 grid-row">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
<section aria-label="Member domains search component" class="margin-top-2">
<form class="usa-search usa-search--show-label" method="POST" role="search">
{% csrf_token %}
<label class="usa-label display-block margin-bottom-05" for="edit-member-domains__search-field">
{% if has_edit_members_portfolio_permission %}
Search all domains
{% else %}
Search domains assigned to
{% if member %}
{{ member.email }}
{% else %}
{{ portfolio_invitation.email }}
{% endif %}
{% endif %}
</label>
<div class="usa-search--show-label__input-wrapper">
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="edit-member-domains__reset-search" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
<input
class="usa-input"
id="edit-member-domains__search-field"
type="search"
name="member-domains-search"
/>
<button class="usa-button" type="submit" id="edit-member-domains__search-field-submit">
<span class="usa-search__submit-text">Search </span>
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</div>
</form>
</section>
</div>
</div>
<!-- ---------- MAIN TABLE ---------- -->
<div class="display-none margin-top-0" id="edit-member-domains__table-wrapper">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<caption class="sr-only">member domains</caption>
<thead>
<tr>
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105"><span class="sr-only">Assigned domains</span></th>
<!-- We override default sort to be name/ascending in the JSON endpoint. We add the correct aria-sort attribute here to reflect that in the UI -->
<th data-sortable="name" scope="col" role="columnheader" aria-sort="descending">Domains</th>
</tr>
</thead>
<tbody>
<!-- AJAX will populate this tbody -->
</tbody>
</table>
<div
class="usa-sr-only usa-table__announcement-region" id="edit-member-domains__usa-table__announcement-region"
aria-live="polite"
></div>
</div>
<div class="display-none" id="edit-member-domains__no-data">
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p>
</div>
<div class="display-none" id="edit-member-domains__no-search-results">
<p>No results found</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="edit-member-domains-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
<!-- Count will be dynamically populated by JS -->
</span>
<ul class="usa-pagination__list">
<!-- Pagination links will be dynamically populated by JS -->
</ul>
</nav>
<a
id="hidden-cancel-edit-domain-assignments-modal-trigger"
href="#cancel-edit-domain-assignments-modal"
class="usa-button usa-button--outline margin-top-1 display-none"
aria-controls="cancel-edit-domain-assignments-modal"
data-open-modal
></a
>
<div
class="usa-modal"
id="cancel-edit-domain-assignments-modal"
aria-labelledby="Are you sure you want to continue?"
aria-describedby="You have unsaved changes that will be lost."
>
{% if portfolio_permission %}
{% url 'member-domains' pk=portfolio_permission.id as url %}
{% else %}
{% url 'invitedmember-domains' pk=portfolio_invitation.id as url %}
{% endif %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="You have unsaved changes that will be lost." modal_button_url=url modal_button_text="Continue without saving" %}
</div>

View file

@ -36,20 +36,16 @@
<div class="section-outlined__header margin-bottom-3 grid-row"> <div class="section-outlined__header margin-bottom-3 grid-row">
<!-- ---------- SEARCH ---------- --> <!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6"> <div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
<section aria-label="Members search component" class="margin-top-2"> <section aria-label="Member domains search component" class="margin-top-2">
<form class="usa-search usa-search--show-label" method="POST" role="search"> <form class="usa-search usa-search--show-label" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<label class="usa-label display-block margin-bottom-05" for="member-domains__search-field"> <label class="usa-label display-block margin-bottom-05" for="member-domains__search-field">
{% if has_edit_members_portfolio_permission %} Search domains assigned to
Search all domains {% if member %}
{{ member.email }}
{% else %} {% else %}
Search domains assigned to {{ portfolio_invitation.email }}
{% if member %}
{{ member.email }}
{% else %}
{{ portfolio_invitation.email }}
{% endif %}
{% endif %} {% endif %}
</label> </label>
<div class="usa-search--show-label__input-wrapper"> <div class="usa-search--show-label__input-wrapper">

View file

@ -23,18 +23,24 @@
<div class="usa-modal__footer"> <div class="usa-modal__footer">
<ul class="usa-button-group"> <ul class="usa-button-group">
{% if not_form %}
<li class="usa-button-group__item"> <li class="usa-button-group__item">
{% if not_form and modal_button %}
{{ modal_button }} {{ modal_button }}
</li> {% elif modal_button_url and modal_button_text %}
{% else %} <a
<li class="usa-button-group__item"> href="{{ modal_button_url }}"
type="button"
class="usa-button"
>
{{ modal_button_text }}
</a>
{% else %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ modal_button }} {{ modal_button }}
</form> </form>
</li> {% endif %}
{% endif %} </li>
<li class="usa-button-group__item"> <li class="usa-button-group__item">
{% comment %} The cancel button the DS form actually triggers a context change in the view, {% comment %} The cancel button the DS form actually triggers a context change in the view,
in addition to being a close modal hook {% endcomment %} in addition to being a close modal hook {% endcomment %}

View file

@ -62,7 +62,7 @@
{% endif %} {% endif %}
{% if step == Step.ADDITIONAL_DETAILS %} {% if step == Step.ADDITIONAL_DETAILS %}
{% with title=form_titles|get_item:step value=domain_request.anything_else|default:"<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe %} {% with title=form_titles|get_item:step value=domain_request.anything_else|default:"None" %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}

View file

@ -4,7 +4,7 @@
<div id="wrapper" class="{% block wrapper_class %}wrapper--padding-top-6{% endblock %}"> <div id="wrapper" class="{% block wrapper_class %}wrapper--padding-top-6{% endblock %}">
{% block content %} {% block content %}
<main class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}"> <main class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
{% if user.is_authenticated %} {% if user.is_authenticated %}
{# the entire logged in page goes here #} {# the entire logged in page goes here #}

View file

@ -2,18 +2,18 @@
{% load static field_helpers %} {% load static field_helpers %}
{% block form_required_fields_help_text %} {% block form_required_fields_help_text %}
{% include "includes/required_fields.html" %} {% comment %} Empty - this step is not required {% endcomment %}
{% endblock %} {% endblock %}
{% block form_fields %} {% block form_fields %}
<fieldset class="usa-fieldset margin-top-2"> <fieldset class="usa-fieldset">
<h2>Is there anything else youd like us to know about your domain request?</h2> <h2 class="margin-top-0 margin-bottom-0">Is there anything else youd like us to know about your domain request?</h2>
</legend> </legend>
</fieldset> </fieldset>
<div class="margin-top-3" id="anything-else"> <div id="anything-else">
<p><em>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em></p> <p><em>This question is optional.</em></p>
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %} {% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.0.anything_else %} {% input_with_errors forms.0.anything_else %}
{% endwith %} {% endwith %}

View file

@ -11,8 +11,10 @@
{% url 'members' as url %} {% url 'members' as url %}
{% if portfolio_permission %} {% if portfolio_permission %}
{% url 'member' pk=portfolio_permission.id as url2 %} {% url 'member' pk=portfolio_permission.id as url2 %}
{% url 'member-domains-edit' pk=portfolio_permission.id as url3 %}
{% else %} {% else %}
{% url 'invitedmember' pk=portfolio_invitation.id as url2 %} {% url 'invitedmember' pk=portfolio_invitation.id as url2 %}
{% url 'invitedmember-domains-edit' pk=portfolio_invitation.id as url3 %}
{% endif %} {% endif %}
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb"> <nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
<ol class="usa-breadcrumb__list"> <ol class="usa-breadcrumb__list">
@ -23,7 +25,7 @@
<a href="{{ url2 }}" class="usa-breadcrumb__link"><span>Manage member</span></a> <a href="{{ url2 }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page"> <li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Manage member</span> <span>Domain assignments</span>
</li> </li>
</ol> </ol>
</nav> </nav>
@ -35,7 +37,7 @@
{% if has_edit_members_portfolio_permission %} {% if has_edit_members_portfolio_permission %}
<div class="mobile:grid-col-12 tablet:grid-col-5"> <div class="mobile:grid-col-12 tablet:grid-col-5">
<p class="float-right-tablet tablet:margin-y-0"> <p class="float-right-tablet tablet:margin-y-0">
<a href="#" class="usa-button" <a href="{{ url3 }}" class="usa-button"
> >
Edit domain assignments Edit domain assignments
</a> </a>

View file

@ -0,0 +1,69 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Edit organization member domains {% endblock %}
{% load static %}
{% block portfolio_content %}
<div id="main-content">
{% url 'members' as url %}
{% if portfolio_permission %}
{% url 'member' pk=portfolio_permission.id as url2 %}
{% url 'member-domains' pk=portfolio_permission.id as url3 %}
{% else %}
{% url 'invitedmember' pk=portfolio_invitation.id as url2 %}
{% url 'invitedmember-domains' pk=portfolio_invitation.id as url3 %}
{% endif %}
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{{ url2 }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{{ url3 }}" class="usa-breadcrumb__link"><span>Domain assignments</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current edit-domain-assignments-breadcrumb" aria-current="page">
<span>Edit domain assignments</span>
</li>
</ol>
</nav>
<h1 class="margin-bottom-3">Edit domain assignments</h1>
<p class="margin-bottom-0">
A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains.
</p>
<p>
When you save this form the member will get an email to notify them of any changes.
</p>
{% include "includes/member_domains_edit_table.html" %}
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
id="cancel-edit-domain-assignments"
type="button"
class="usa-button usa-button--outline"
>
Cancel
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button"
>
Review
</button>
</li>
</ul>
</div>
{% endblock %}

View file

@ -0,0 +1,34 @@
{% extends "domain_base.html" %}
{% load static field_helpers url_helpers %}
{% block title %}Prototype DNS | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{% include "includes/form_errors.html" with form=form %}
<h1>Add DNS records</h1>
<p>
This is a prototype that demonstrates adding an 'A' record to a zone.
Do note that this just adds records, but does not update or delete existing ones.
</p>
<p>
You can only use this functionality on a limited set of domains:
<strong>
igorville.gov, dns.gov (non-prod), and domainops.gov (non-prod).
</strong>
</p>
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{% input_with_errors form.name %}
{% input_with_errors form.content %}
{% input_with_errors form.ttl %}
<button
type="submit"
class="usa-button"
>
Add record
</button>
</form>
{% endblock %} {# domain_content #}

View file

@ -57,6 +57,7 @@ def input_with_errors(context, field=None): # noqa: C901
legend_classes = [] legend_classes = []
group_classes = [] group_classes = []
aria_labels = [] aria_labels = []
sublabel_text = []
# this will be converted to an attribute string # this will be converted to an attribute string
described_by = [] described_by = []
@ -103,6 +104,9 @@ def input_with_errors(context, field=None): # noqa: C901
elif key == "add_aria_label": elif key == "add_aria_label":
aria_labels.append(value) aria_labels.append(value)
elif key == "sublabel_text":
sublabel_text.append(value)
attrs["id"] = field.auto_id attrs["id"] = field.auto_id
# do some work for various edge cases # do some work for various edge cases
@ -152,11 +156,16 @@ def input_with_errors(context, field=None): # noqa: C901
if group_classes: if group_classes:
context["group_classes"] = " ".join(group_classes) context["group_classes"] = " ".join(group_classes)
# We handle sublabel_text here instead of directy in the template to avoid conflicts
if sublabel_text:
sublabel_div_id = f"{attrs['id']}__sublabel"
described_by.insert(0, sublabel_div_id)
if described_by: if described_by:
# ensure we don't overwrite existing attribute value # ensure we don't overwrite existing attribute value
if "aria-describedby" in attrs: if "aria-describedby" in attrs:
described_by.append(attrs["aria-describedby"]) described_by.append(attrs["aria-describedby"])
attrs["aria_describedby"] = " ".join(described_by) attrs["aria-describedby"] = " ".join(described_by)
if aria_labels: if aria_labels:
context["aria_label"] = " ".join(aria_labels) context["aria_label"] = " ".join(aria_labels)

View file

@ -563,9 +563,12 @@ class MockDb(TestCase):
cls.federal_agency_1, _ = FederalAgency.objects.get_or_create(agency="World War I Centennial Commission") cls.federal_agency_1, _ = FederalAgency.objects.get_or_create(agency="World War I Centennial Commission")
cls.federal_agency_2, _ = FederalAgency.objects.get_or_create(agency="Armed Forces Retirement Home") cls.federal_agency_2, _ = FederalAgency.objects.get_or_create(agency="Armed Forces Retirement Home")
cls.federal_agency_3, _ = FederalAgency.objects.get_or_create(
agency="Portfolio 1 Federal Agency", federal_type="executive"
)
cls.portfolio_1, _ = Portfolio.objects.get_or_create( cls.portfolio_1, _ = Portfolio.objects.get_or_create(
creator=cls.custom_superuser, federal_agency=cls.federal_agency_1 creator=cls.custom_superuser, federal_agency=cls.federal_agency_3, organization_type="federal"
) )
current_date = get_time_aware_date(datetime(2024, 4, 2)) current_date = get_time_aware_date(datetime(2024, 4, 2))

View file

@ -779,9 +779,9 @@ class TestDomainAdminWithClient(TestCase):
response = self.client.get("/admin/registrar/domain/") response = self.client.get("/admin/registrar/domain/")
# There are 4 template references to Federal (4) plus four references in the table # There are 4 template references to Federal (4) plus four references in the table
# for our actual domain_request # for our actual domain_request
self.assertContains(response, "Federal", count=56) self.assertContains(response, "Federal", count=57)
# This may be a bit more robust # This may be a bit more robust
self.assertContains(response, '<td class="field-generic_org_type">Federal</td>', count=1) self.assertContains(response, '<td class="field-converted_generic_org_type">Federal</td>', count=1)
# Now let's make sure the long description does not exist # Now let's make sure the long description does not exist
self.assertNotContains(response, "Federal: an agency of the U.S. government") self.assertNotContains(response, "Federal: an agency of the U.S. government")

View file

@ -203,7 +203,7 @@ class TestDomainRequestAdmin(MockEppLib):
domain_request.save() domain_request.save()
domain_request.action_needed() domain_request.action_needed()
domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_A_DOMAIN
domain_request.save() domain_request.save()
# Let's just change the action needed reason # Let's just change the action needed reason
@ -230,7 +230,7 @@ class TestDomainRequestAdmin(MockEppLib):
"In review", "In review",
"Rejected - Purpose requirements not met", "Rejected - Purpose requirements not met",
"Action needed - Unclear organization eligibility", "Action needed - Unclear organization eligibility",
"Action needed - Already has domains", "Action needed - Already has a domain",
"In review", "In review",
"Submitted", "Submitted",
"Started", "Started",
@ -241,7 +241,7 @@ class TestDomainRequestAdmin(MockEppLib):
assert_status_count(normalized_content, "Started", 1) assert_status_count(normalized_content, "Started", 1)
assert_status_count(normalized_content, "Submitted", 1) assert_status_count(normalized_content, "Submitted", 1)
assert_status_count(normalized_content, "In review", 2) assert_status_count(normalized_content, "In review", 2)
assert_status_count(normalized_content, "Action needed - Already has domains", 1) assert_status_count(normalized_content, "Action needed - Already has a domain", 1)
assert_status_count(normalized_content, "Action needed - Unclear organization eligibility", 1) assert_status_count(normalized_content, "Action needed - Unclear organization eligibility", 1)
assert_status_count(normalized_content, "Rejected - Purpose requirements not met", 1) assert_status_count(normalized_content, "Rejected - Purpose requirements not met", 1)
@ -576,9 +576,9 @@ class TestDomainRequestAdmin(MockEppLib):
response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal") response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal")
# There are 2 template references to Federal (4) and two in the results data # There are 2 template references to Federal (4) and two in the results data
# of the request # of the request
self.assertContains(response, "Federal", count=51) self.assertContains(response, "Federal", count=55)
# This may be a bit more robust # This may be a bit more robust
self.assertContains(response, '<td class="field-converted_generic_org_type">federal</td>', count=1) self.assertContains(response, '<td class="field-converted_generic_org_type">Federal</td>', count=1)
# Now let's make sure the long description does not exist # Now let's make sure the long description does not exist
self.assertNotContains(response, "Federal: an agency of the U.S. government") self.assertNotContains(response, "Federal: an agency of the U.S. government")
@ -685,9 +685,9 @@ class TestDomainRequestAdmin(MockEppLib):
# Create a sample domain request # Create a sample domain request
domain_request = completed_domain_request(status=in_review, user=_creator) domain_request = completed_domain_request(status=in_review, user=_creator)
# Test the email sent out for already_has_domains # Test the email sent out for already_has_a_domain
already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS already_has_a_domain = DomainRequest.ActionNeededReasons.ALREADY_HAS_A_DOMAIN
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_a_domain)
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@ -1693,7 +1693,6 @@ class TestDomainRequestAdmin(MockEppLib):
"notes", "notes",
"alternative_domains", "alternative_domains",
] ]
self.maxDiff = None
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)
def test_readonly_fields_for_analyst(self): def test_readonly_fields_for_analyst(self):
@ -1702,7 +1701,6 @@ class TestDomainRequestAdmin(MockEppLib):
request.user = self.staffuser request.user = self.staffuser
readonly_fields = self.admin.get_readonly_fields(request) readonly_fields = self.admin.get_readonly_fields(request)
self.maxDiff = None
expected_fields = [ expected_fields = [
"portfolio_senior_official", "portfolio_senior_official",
"portfolio_organization_type", "portfolio_organization_type",

View file

@ -63,7 +63,6 @@ class TestGroups(TestCase):
# Get the codenames of actual permissions associated with the group # Get the codenames of actual permissions associated with the group
actual_permissions = [p.codename for p in cisa_analysts_group.permissions.all()] actual_permissions = [p.codename for p in cisa_analysts_group.permissions.all()]
self.maxDiff = None
# Assert that the actual permissions match the expected permissions # Assert that the actual permissions match the expected permissions
self.assertListEqual(actual_permissions, expected_permissions) self.assertListEqual(actual_permissions, expected_permissions)

View file

@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
] ]
@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"),
@ -251,32 +251,35 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# We expect READY domains, # We expect READY domains,
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO," "Domain name,Status,First ready on,Expiration date,Domain type,Agency,"
"SO email,Security contact email,Domain managers,Invited domain managers\n" "Organization name,City,State,SO,SO email,"
"cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,," "Security contact email,Domain managers,Invited domain managers\n"
"meoward@rocks.com,\n" "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,"
"defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,," "Portfolio 1 Federal Agency,,,, ,,(blank),"
',,,(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",'
"woofwardthethird@rocks.com\n"
"adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,"
"squeaker@rocks.com\n"
"bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,,"
"security@mail.gov,,\n"
"sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,,"
"meoward@rocks.com,squeaker@rocks.com\n" "meoward@rocks.com,squeaker@rocks.com\n"
"zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n" "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,"
"Portfolio 1 Federal Agency,,,, ,,(blank),"
'"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n'
"cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,"
"World War I Centennial Commission,,,, ,,(blank),"
"meoward@rocks.com,\n"
"adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,"
"squeaker@rocks.com\n"
"bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
"bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
"bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
"ddomain3.gov,On hold,(blank),2023-11-15,Federal,"
"Armed Forces Retirement Home,,,, ,,security@mail.gov,,\n"
"sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
"xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
"zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
"zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -312,20 +315,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# We expect only domains associated with the user # We expect only domains associated with the user
expected_content = ( expected_content = (
"Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name," "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,"
"City,State,SO,SO email," "City,State,SO,SO email,Security contact email,Domain managers,Invited domain managers\n"
"Security contact email,Domain managers,Invited domain managers\n" "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank),"
"defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,, ,,"
'(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",'
"woofwardthethird@rocks.com\n"
"adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,(blank),"
'"info@example.com, meoward@rocks.com",squeaker@rocks.com\n' '"info@example.com, meoward@rocks.com",squeaker@rocks.com\n'
"defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank),"
'"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n'
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n"
"zdomain12.gov,Interstate,,,,,(blank)\n" "zdomain12.gov,Interstate,,,,,(blank)\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -587,13 +587,13 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City," "Domain name,Domain type,Agency,Organization name,City,"
"State,Status,Expiration date, Deleted\n" "State,Status,Expiration date, Deleted\n"
"cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n" "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Ready,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n" "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n"
"cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n" "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n"
"zdomain12.govInterstateReady(blank)\n" "zdomain12.gov,Interstate,Ready,(blank)\n"
"zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n"
"sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank),2024-04-02\n" "sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n"
"xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" "xdomain7.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
@ -611,7 +611,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers).
She should show twice in this report but not in test_DomainManaged.""" She should show twice in this report but not in test_DomainManaged."""
self.maxDiff = None
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
# Call the export functions # Call the export functions
@ -646,7 +645,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -683,7 +681,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -721,10 +718,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator # @less_console_noise_decorator
def test_domain_request_data_full(self): def test_domain_request_data_full(self):
"""Tests the full domain request report.""" """Tests the full domain request report."""
# Remove "Submitted at" because we can't guess this immutable, dynamically generated test data # Remove "Submitted at" because we can't guess this immutable, dynamically generated test data
@ -766,35 +762,34 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
csv_content = csv_file.read() csv_content = csv_file.read()
expected_content = ( expected_content = (
# Header # Header
"Domain request,Status,Domain type,Federal type," "Domain request,Status,Domain type,Federal type,Federal agency,Organization name,Election office,"
"Federal agency,Organization name,Election office,City,State/territory," "City,State/territory,Region,Creator first name,Creator last name,Creator email,"
"Region,Creator first name,Creator last name,Creator email,Creator approved domains count," "Creator approved domains count,Creator active requests count,Alternative domains,SO first name,"
"Creator active requests count,Alternative domains,SO first name,SO last name,SO email," "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts,"
"SO title/role,Request purpose,Request additional details,Other contacts,"
"CISA regional representative,Current websites,Investigator\n" "CISA regional representative,Current websites,Investigator\n"
# Content # Content
"city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," "city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com,"
"Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
"city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester," "city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,,"
"testy@town.com," "Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
"Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" "city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,"
'city3.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"cheeseville.gov, city1.gov,' '"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | '
'igorville.gov",Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,CISA-first-name ' 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, '
"CISA-last-name " 'Testy Tester testy2@town.com",'
'| There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, Testy Tester ' 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n'
'testy2@town.com"' "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,"
',test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' "Tester,testy@town.com,"
"city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,"
"Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " "Testy Tester testy2@town.com,"
"testy2@town.com" "cisaRep@igorville.gov,city.com,\n"
",cisaRep@igorville.gov,city.com,\n" "city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,,"
"city6.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," "Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com,"
"Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester "
"testy2@town.com,"
"cisaRep@igorville.gov,city.com,\n" "cisaRep@igorville.gov,city.com,\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
@ -862,7 +857,6 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
# Create a request and add the user to the request # Create a request and add the user to the request
request = self.factory.get("/") request = self.factory.get("/")
request.user = self.user request.user = self.user
self.maxDiff = None
# Add portfolio to session # Add portfolio to session
request = GenericTestHelper._mock_user_request_for_factory(request) request = GenericTestHelper._mock_user_request_for_factory(request)
request.session["portfolio"] = self.portfolio_1 request.session["portfolio"] = self.portfolio_1
@ -880,18 +874,26 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
"Email,Organization admin,Invited by,Joined date,Last active,Domain requests," "Email,Organization admin,Invited by,Joined date,Last active,Domain requests,"
"Member management,Domain management,Number of domains,Domains\n" "Member management,Domain management,Number of domains,Domains\n"
# Content # Content
"big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None,Viewer,True,1,cdomain1.gov\n" "big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None,"
"cozy_staffuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,Viewer,Viewer,False,0,\n" "Viewer,True,1,cdomain1.gov\n"
"icy_superuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,Viewer Requester,Manager,False,0,\n" "cozy_staffuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,"
"Viewer,Viewer,False,0,\n"
"icy_superuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,"
"Viewer Requester,Manager,False,0,\n"
"meoward@rocks.com,False,big_lebowski@dude.co,2022-04-01,Invalid date,None," "meoward@rocks.com,False,big_lebowski@dude.co,2022-04-01,Invalid date,None,"
'Manager,True,2,"adomain2.gov,cdomain1.gov"\n' 'Manager,True,2,"adomain2.gov,cdomain1.gov"\n'
"nonexistentmember_1@igorville.gov,False,help@get.gov,Unretrieved,Invited,None,Manager,False,0,\n" "nonexistentmember_1@igorville.gov,False,help@get.gov,Unretrieved,Invited,"
"nonexistentmember_2@igorville.gov,False,help@get.gov,Unretrieved,Invited,None,Viewer,False,0,\n" "None,Manager,False,0,\n"
"nonexistentmember_3@igorville.gov,False,help@get.gov,Unretrieved,Invited,Viewer,None,False,0,\n" "nonexistentmember_2@igorville.gov,False,help@get.gov,Unretrieved,Invited,"
"nonexistentmember_4@igorville.gov,True,help@get.gov,Unretrieved," "None,Viewer,False,0,\n"
"Invited,Viewer Requester,Manager,False,0,\n" "nonexistentmember_3@igorville.gov,False,help@get.gov,Unretrieved,Invited,"
"nonexistentmember_5@igorville.gov,True,help@get.gov,Unretrieved,Invited,Viewer,Viewer,False,0,\n" "Viewer,None,False,0,\n"
"tired_sleepy@igorville.gov,False,System,2022-04-01,Invalid date,Viewer,None,False,0,\n" "nonexistentmember_4@igorville.gov,True,help@get.gov,Unretrieved,Invited,"
"Viewer Requester,Manager,False,0,\n"
"nonexistentmember_5@igorville.gov,True,help@get.gov,Unretrieved,Invited,"
"Viewer,Viewer,False,0,\n"
"tired_sleepy@igorville.gov,False,System,2022-04-01,Invalid date,Viewer,"
"None,False,0,\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace

View file

@ -58,10 +58,13 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
cls.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-03-01", state="ready") cls.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-03-01", state="ready")
cls.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-03-01", state="ready") cls.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-03-01", state="ready")
cls.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready") cls.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready")
cls.domain4 = Domain.objects.create(name="example4.com", expiration_date="2024-03-01", state="ready")
# Add domain1 and domain2 to portfolio # Add domain1 and domain2 to portfolio
DomainInformation.objects.create(creator=cls.user, domain=cls.domain1, portfolio=cls.portfolio) DomainInformation.objects.create(creator=cls.user, domain=cls.domain1, portfolio=cls.portfolio)
DomainInformation.objects.create(creator=cls.user, domain=cls.domain2, portfolio=cls.portfolio) DomainInformation.objects.create(creator=cls.user, domain=cls.domain2, portfolio=cls.portfolio)
DomainInformation.objects.create(creator=cls.user, domain=cls.domain3, portfolio=cls.portfolio) DomainInformation.objects.create(creator=cls.user, domain=cls.domain3, portfolio=cls.portfolio)
DomainInformation.objects.create(creator=cls.user, domain=cls.domain4, portfolio=cls.portfolio)
# Assign user_member to view all domains # Assign user_member to view all domains
UserPortfolioPermission.objects.create( UserPortfolioPermission.objects.create(
@ -70,8 +73,10 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
) )
# Add user_member as manager of domains # Add user_member as manager of domains
UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain1) UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain1, role=UserDomainRole.Roles.MANAGER)
UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain2) UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain2, role=UserDomainRole.Roles.MANAGER)
UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain3, role=UserDomainRole.Roles.MANAGER)
UserDomainRole.objects.create(user=cls.user_no_perms, domain=cls.domain3, role=UserDomainRole.Roles.MANAGER)
# Add an invited member who has been invited to manage domains # Add an invited member who has been invited to manage domains
cls.invited_member_email = "invited@example.com" cls.invited_member_email = "invited@example.com"
@ -123,11 +128,11 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"]) self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 2) self.assertEqual(data["total"], 3)
self.assertEqual(data["unfiltered_total"], 2) self.assertEqual(data["unfiltered_total"], 3)
# Check the number of domains # Check the number of domains
self.assertEqual(len(data["domains"]), 2) self.assertEqual(len(data["domains"]), 3)
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@ -169,11 +174,11 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"]) self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 3) self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 3) self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains # Check the number of domains
self.assertEqual(len(data["domains"]), 3) self.assertEqual(len(data["domains"]), 4)
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@ -192,11 +197,11 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"]) self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 3) self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 3) self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains # Check the number of domains
self.assertEqual(len(data["domains"]), 3) self.assertEqual(len(data["domains"]), 4)
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@ -221,7 +226,7 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_next"]) self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 1) self.assertEqual(data["total"], 1)
self.assertEqual(data["unfiltered_total"], 3) self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains # Check the number of domains
self.assertEqual(len(data["domains"]), 1) self.assertEqual(len(data["domains"]), 1)
@ -249,7 +254,7 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_next"]) self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 1) self.assertEqual(data["total"], 1)
self.assertEqual(data["unfiltered_total"], 3) self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains # Check the number of domains
self.assertEqual(len(data["domains"]), 1) self.assertEqual(len(data["domains"]), 1)
@ -278,11 +283,11 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"]) self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 3) self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 3) self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains # Check the number of domains
self.assertEqual(len(data["domains"]), 3) self.assertEqual(len(data["domains"]), 4)
# Check the name of the first domain is example1.com # Check the name of the first domain is example1.com
self.assertEqual(data["domains"][0]["name"], "example1.com") self.assertEqual(data["domains"][0]["name"], "example1.com")
@ -306,14 +311,121 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"]) self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 3) self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 3) self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains # Check the number of domains
self.assertEqual(len(data["domains"]), 3) self.assertEqual(len(data["domains"]), 4)
# Check the name of the first domain is example1.com # Check the name of the first domain is example1.com
self.assertEqual(data["domains"][0]["name"], "example3.com") self.assertEqual(data["domains"][0]["name"], "example4.com")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_get_portfolio_member_domains_json_authenticated_sort_by_checked(self):
"""Test that sort returns results in correct order."""
# Test by checked in ascending order
response = self.app.get(
reverse("get_member_domains_json"),
params={
"portfolio": self.portfolio.id,
"email": self.user_member.id,
"member_only": "false",
"checkedDomainIds": f"{self.domain2.id},{self.domain3.id}",
"sort_by": "checked",
"order": "asc",
},
)
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains
self.assertEqual(len(data["domains"]), 4)
# Check the name of the first domain is the first unchecked domain sorted alphabetically
self.assertEqual(data["domains"][0]["name"], "example1.com")
self.assertEqual(data["domains"][1]["name"], "example4.com")
# Test by checked in descending order
response = self.app.get(
reverse("get_member_domains_json"),
params={
"portfolio": self.portfolio.id,
"email": self.user_member.id,
"member_only": "false",
"checkedDomainIds": f"{self.domain2.id},{self.domain3.id}",
"sort_by": "checked",
"order": "desc",
},
)
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains
self.assertEqual(len(data["domains"]), 4)
# Check the name of the first domain is the first checked domain sorted alphabetically
self.assertEqual(data["domains"][0]["name"], "example2.com")
self.assertEqual(data["domains"][1]["name"], "example3.com")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_get_portfolio_member_domains_json_authenticated_member_is_only_manager(self):
"""Test that sort returns member_is_only_manager when member_domain_role_exists
and member_domain_role_count == 1"""
response = self.app.get(
reverse("get_member_domains_json"),
params={
"portfolio": self.portfolio.id,
"member_id": self.user_member.id,
"member_only": "false",
"sort_by": "name",
"order": "asc",
},
)
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains
self.assertEqual(len(data["domains"]), 4)
self.assertEqual(data["domains"][0]["name"], "example1.com")
self.assertEqual(data["domains"][1]["name"], "example2.com")
self.assertEqual(data["domains"][2]["name"], "example3.com")
self.assertEqual(data["domains"][3]["name"], "example4.com")
self.assertEqual(data["domains"][0]["member_is_only_manager"], True)
self.assertEqual(data["domains"][1]["member_is_only_manager"], True)
# domain3 has 2 managers
self.assertEqual(data["domains"][2]["member_is_only_manager"], False)
# no managers on this one
self.assertEqual(data["domains"][3]["member_is_only_manager"], False)
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@ -339,11 +451,11 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"]) self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 3) self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 3) self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains # Check the number of domains
self.assertEqual(len(data["domains"]), 3) self.assertEqual(len(data["domains"]), 4)
# Check the name of the first domain is example1.com # Check the name of the first domain is example1.com
self.assertEqual(data["domains"][0]["name"], "example1.com") self.assertEqual(data["domains"][0]["name"], "example1.com")
@ -367,14 +479,79 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"]) self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 3) self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 3) self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains # Check the number of domains
self.assertEqual(len(data["domains"]), 3) self.assertEqual(len(data["domains"]), 4)
# Check the name of the first domain is example1.com # Check the name of the first domain is example1.com
self.assertEqual(data["domains"][0]["name"], "example3.com") self.assertEqual(data["domains"][0]["name"], "example4.com")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_get_portfolio_invitedmember_domains_json_authenticated_sort_by_checked(self):
"""Test that sort returns results in correct order."""
# Test by checked in ascending order
response = self.app.get(
reverse("get_member_domains_json"),
params={
"portfolio": self.portfolio.id,
"email": self.invited_member_email,
"member_only": "false",
"checkedDomainIds": f"{self.domain2.id},{self.domain3.id}",
"sort_by": "checked",
"order": "asc",
},
)
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains
self.assertEqual(len(data["domains"]), 4)
# Check the name of the first domain is the first unchecked domain sorted alphabetically
self.assertEqual(data["domains"][0]["name"], "example1.com")
self.assertEqual(data["domains"][1]["name"], "example4.com")
# Test by checked in descending order
response = self.app.get(
reverse("get_member_domains_json"),
params={
"portfolio": self.portfolio.id,
"email": self.invited_member_email,
"member_only": "false",
"checkedDomainIds": f"{self.domain2.id},{self.domain3.id}",
"sort_by": "checked",
"order": "desc",
},
)
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 4)
self.assertEqual(data["unfiltered_total"], 4)
# Check the number of domains
self.assertEqual(len(data["domains"]), 4)
# Check the name of the first domain is the first checked domain sorted alphabetically
self.assertEqual(data["domains"][0]["name"], "example2.com")
self.assertEqual(data["domains"][1]["name"], "example3.com")
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)

View file

@ -2102,6 +2102,127 @@ class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView):
@classmethod
def setUpClass(cls):
super().setUpClass()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_domains_edit_authenticated(self):
"""Tests that the portfolio member domains edit view is accessible."""
self.client.force_login(self.user)
response = self.client.get(reverse("member-domains-edit", kwargs={"pk": self.permission.id}))
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.user_member.email)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_domains_edit_no_perms(self):
"""Tests that the portfolio member domains edit view is not accessible to user with no perms."""
self.client.force_login(self.user_no_perms)
response = self.client.get(reverse("member-domains-edit", kwargs={"pk": self.permission.id}))
# Make sure the request returns forbidden
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_domains_edit_unauthenticated(self):
"""Tests that the portfolio member domains edit view is not accessible when no authenticated user."""
self.client.logout()
response = self.client.get(reverse("member-domains-edit", kwargs={"pk": self.permission.id}))
# Make sure the request returns redirect to openid login
self.assertEqual(response.status_code, 302) # Redirect to openid login
self.assertIn("/openid/login", response.url)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_domains_edit_not_found(self):
"""Tests that the portfolio member domains edit view returns not found if user
portfolio permission not found."""
self.client.force_login(self.user)
response = self.client.get(reverse("member-domains-edit", kwargs={"pk": "0"}))
# Make sure the response is not found
self.assertEqual(response.status_code, 404)
class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomainsView):
@classmethod
def setUpClass(cls):
super().setUpClass()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_invitedmember_domains_edit_authenticated(self):
"""Tests that the portfolio invited member domains edit view is accessible."""
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.id}))
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.invited_member_email)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_invitedmember_domains_edit_no_perms(self):
"""Tests that the portfolio invited member domains edit view is not accessible to user with no perms."""
self.client.force_login(self.user_no_perms)
response = self.client.get(reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.id}))
# Make sure the request returns forbidden
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_invitedmember_domains_edit_unauthenticated(self):
"""Tests that the portfolio invited member domains edit view is not accessible when no authenticated user."""
self.client.logout()
response = self.client.get(reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.id}))
# Make sure the request returns redirect to openid login
self.assertEqual(response.status_code, 302) # Redirect to openid login
self.assertIn("/openid/login", response.url)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_domains_edit_not_found(self):
"""Tests that the portfolio invited member domains edit view returns not found if user is not a member."""
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember-domains-edit", kwargs={"pk": "0"}))
# Make sure the response is not found
self.assertEqual(response.status_code, 404)
class TestRequestingEntity(WebTest): class TestRequestingEntity(WebTest):
"""The requesting entity page is a domain request form that only exists """The requesting entity page is a domain request form that only exists
within the context of a portfolio.""" within the context of a portfolio."""

View file

@ -414,7 +414,9 @@ class MemberExport(BaseExport):
) )
.values(*shared_columns) .values(*shared_columns)
) )
# Adding a order_by increases output predictability.
# Doesn't matter as much for normal use, but makes tests easier.
# We should also just be ordering by default anyway.
members = permissions.union(invitations).order_by("email_display") members = permissions.union(invitations).order_by("email_display")
return convert_queryset_to_dict(members, is_model=False) return convert_queryset_to_dict(members, is_model=False)
@ -526,6 +528,115 @@ class DomainExport(BaseExport):
# Return the model class that this export handles # Return the model class that this export handles
return DomainInformation return DomainInformation
@classmethod
def get_computed_fields(cls, **kwargs):
"""
Get a dict of computed fields.
"""
# NOTE: These computed fields imitate @Property functions in the Domain model and Portfolio model where needed.
# This is for performance purposes. Since we are working with dictionary values and not
# model objects as we export data, trying to reinstate model objects in order to grab @property
# values negatively impacts performance. Therefore, we will follow best practice and use annotations
return {
"converted_generic_org_type": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__organization_type")),
# Otherwise, return the natively assigned value
default=F("generic_org_type"),
output_field=CharField(),
),
"converted_federal_agency": Case(
# When portfolio is present, use its value instead
When(
Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False),
then=F("portfolio__federal_agency__agency"),
),
# Otherwise, return the natively assigned value
default=F("federal_agency__agency"),
output_field=CharField(),
),
"converted_federal_type": Case(
# When portfolio is present, use its value instead
# NOTE: this is an @Property funciton in portfolio.
When(
Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False),
then=F("portfolio__federal_agency__federal_type"),
),
# Otherwise, return the natively assigned value
default=F("federal_type"),
output_field=CharField(),
),
"converted_organization_name": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__organization_name")),
# Otherwise, return the natively assigned value
default=F("organization_name"),
output_field=CharField(),
),
"converted_city": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__city")),
# Otherwise, return the natively assigned value
default=F("city"),
output_field=CharField(),
),
"converted_state_territory": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__state_territory")),
# Otherwise, return the natively assigned value
default=F("state_territory"),
output_field=CharField(),
),
"converted_so_email": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__email")),
# Otherwise, return the natively assigned senior official
default=F("senior_official__email"),
output_field=CharField(),
),
"converted_senior_official_last_name": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__last_name")),
# Otherwise, return the natively assigned senior official
default=F("senior_official__last_name"),
output_field=CharField(),
),
"converted_senior_official_first_name": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__first_name")),
# Otherwise, return the natively assigned senior official
default=F("senior_official__first_name"),
output_field=CharField(),
),
"converted_senior_official_title": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__title")),
# Otherwise, return the natively assigned senior official
default=F("senior_official__title"),
output_field=CharField(),
),
"converted_so_name": Case(
# When portfolio is present, use that senior official instead
When(
Q(portfolio__isnull=False) & Q(portfolio__senior_official__isnull=False),
then=Concat(
Coalesce(F("portfolio__senior_official__first_name"), Value("")),
Value(" "),
Coalesce(F("portfolio__senior_official__last_name"), Value("")),
output_field=CharField(),
),
),
# Otherwise, return the natively assigned senior official
default=Concat(
Coalesce(F("senior_official__first_name"), Value("")),
Value(" "),
Coalesce(F("senior_official__last_name"), Value("")),
output_field=CharField(),
),
output_field=CharField(),
),
}
@classmethod @classmethod
def update_queryset(cls, queryset, **kwargs): def update_queryset(cls, queryset, **kwargs):
""" """
@ -615,10 +726,10 @@ class DomainExport(BaseExport):
if first_ready_on is None: if first_ready_on is None:
first_ready_on = "(blank)" first_ready_on = "(blank)"
# organization_type has generic_org_type AND is_election # organization_type has organization_type AND is_election
domain_org_type = model.get("organization_type") domain_org_type = model.get("converted_generic_org_type")
human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type)
domain_federal_type = model.get("federal_type") domain_federal_type = model.get("converted_federal_type")
human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type)
domain_type = human_readable_domain_org_type domain_type = human_readable_domain_org_type
if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL:
@ -641,12 +752,12 @@ class DomainExport(BaseExport):
"First ready on": first_ready_on, "First ready on": first_ready_on,
"Expiration date": expiration_date, "Expiration date": expiration_date,
"Domain type": domain_type, "Domain type": domain_type,
"Agency": model.get("federal_agency__agency"), "Agency": model.get("converted_federal_agency"),
"Organization name": model.get("organization_name"), "Organization name": model.get("converted_organization_name"),
"City": model.get("city"), "City": model.get("converted_city"),
"State": model.get("state_territory"), "State": model.get("converted_state_territory"),
"SO": model.get("so_name"), "SO": model.get("converted_so_name"),
"SO email": model.get("senior_official__email"), "SO email": model.get("converted_so_email"),
"Security contact email": security_contact_email, "Security contact email": security_contact_email,
"Created at": model.get("domain__created_at"), "Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"), "Deleted": model.get("domain__deleted"),
@ -655,8 +766,23 @@ class DomainExport(BaseExport):
} }
row = [FIELDS.get(column, "") for column in columns] row = [FIELDS.get(column, "") for column in columns]
return row return row
def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by):
"""Returns a list of Domain Requests that has been filtered by the given organization value."""
annotated_queryset = domain_infos_to_filter.annotate(
converted_generic_org_type=Case(
# Recreate the logic of the converted_generic_org_type property
# here in annotations
When(portfolio__isnull=False, then=F("portfolio__organization_type")),
default=F("generic_org_type"),
output_field=CharField(),
)
)
return annotated_queryset.filter(converted_generic_org_type=org_to_filter_by)
@classmethod @classmethod
def get_sliced_domains(cls, filter_condition): def get_sliced_domains(cls, filter_condition):
"""Get filtered domains counts sliced by org type and election office. """Get filtered domains counts sliced by org type and election office.
@ -664,23 +790,51 @@ class DomainExport(BaseExport):
when a domain has more that one manager. when a domain has more that one manager.
""" """
domains = DomainInformation.objects.all().filter(**filter_condition).distinct() domain_informations = DomainInformation.objects.all().filter(**filter_condition).distinct()
domains_count = domains.count() domains_count = domain_informations.count()
federal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count() federal = (
interstate = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).count() cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.FEDERAL)
state_or_territory = ( .distinct()
domains.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count() .count()
)
interstate = cls.get_filtered_domain_infos_by_org(
domain_informations, DomainRequest.OrganizationChoices.INTERSTATE
).count()
state_or_territory = (
cls.get_filtered_domain_infos_by_org(
domain_informations, DomainRequest.OrganizationChoices.STATE_OR_TERRITORY
)
.distinct()
.count()
)
tribal = (
cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.TRIBAL)
.distinct()
.count()
)
county = (
cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.COUNTY)
.distinct()
.count()
)
city = (
cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.CITY)
.distinct()
.count()
) )
tribal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count()
county = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count()
city = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count()
special_district = ( special_district = (
domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count() cls.get_filtered_domain_infos_by_org(
domain_informations, DomainRequest.OrganizationChoices.SPECIAL_DISTRICT
)
.distinct()
.count()
) )
school_district = ( school_district = (
domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.SCHOOL_DISTRICT)
.distinct()
.count()
) )
election_board = domains.filter(is_election_board=True).distinct().count() election_board = domain_informations.filter(is_election_board=True).distinct().count()
return [ return [
domains_count, domains_count,
@ -707,6 +861,7 @@ class DomainDataType(DomainExport):
""" """
Overrides the columns for CSV export specific to DomainExport. Overrides the columns for CSV export specific to DomainExport.
""" """
return [ return [
"Domain name", "Domain name",
"Status", "Status",
@ -724,6 +879,13 @@ class DomainDataType(DomainExport):
"Invited domain managers", "Invited domain managers",
] ]
@classmethod
def get_annotations_for_sort(cls):
"""
Get a dict of annotations to make available for sorting.
"""
return cls.get_computed_fields()
@classmethod @classmethod
def get_sort_fields(cls): def get_sort_fields(cls):
""" """
@ -731,9 +893,9 @@ class DomainDataType(DomainExport):
""" """
# Coalesce is used to replace federal_type of None with ZZZZZ # Coalesce is used to replace federal_type of None with ZZZZZ
return [ return [
"organization_type", "converted_generic_org_type",
Coalesce("federal_type", Value("ZZZZZ")), Coalesce("converted_federal_type", Value("ZZZZZ")),
"federal_agency", "converted_federal_agency",
"domain__name", "domain__name",
] ]
@ -774,20 +936,6 @@ class DomainDataType(DomainExport):
""" """
return ["domain__permissions"] return ["domain__permissions"]
@classmethod
def get_computed_fields(cls, delimiter=", ", **kwargs):
"""
Get a dict of computed fields.
"""
return {
"so_name": Concat(
Coalesce(F("senior_official__first_name"), Value("")),
Value(" "),
Coalesce(F("senior_official__last_name"), Value("")),
output_field=CharField(),
),
}
@classmethod @classmethod
def get_related_table_fields(cls): def get_related_table_fields(cls):
""" """
@ -893,7 +1041,7 @@ class DomainRequestsDataType:
cls.safe_get(getattr(request, "region_field", None)), cls.safe_get(getattr(request, "region_field", None)),
request.status, request.status,
cls.safe_get(getattr(request, "election_office", None)), cls.safe_get(getattr(request, "election_office", None)),
request.federal_type, request.converted_federal_type,
cls.safe_get(getattr(request, "domain_type", None)), cls.safe_get(getattr(request, "domain_type", None)),
cls.safe_get(getattr(request, "additional_details", None)), cls.safe_get(getattr(request, "additional_details", None)),
cls.safe_get(getattr(request, "creator_approved_domains_count", None)), cls.safe_get(getattr(request, "creator_approved_domains_count", None)),
@ -944,6 +1092,13 @@ class DomainDataFull(DomainExport):
"Security contact email", "Security contact email",
] ]
@classmethod
def get_annotations_for_sort(cls, delimiter=", "):
"""
Get a dict of annotations to make available for sorting.
"""
return cls.get_computed_fields()
@classmethod @classmethod
def get_sort_fields(cls): def get_sort_fields(cls):
""" """
@ -951,9 +1106,9 @@ class DomainDataFull(DomainExport):
""" """
# Coalesce is used to replace federal_type of None with ZZZZZ # Coalesce is used to replace federal_type of None with ZZZZZ
return [ return [
"organization_type", "converted_generic_org_type",
Coalesce("federal_type", Value("ZZZZZ")), Coalesce("converted_federal_type", Value("ZZZZZ")),
"federal_agency", "converted_federal_agency",
"domain__name", "domain__name",
] ]
@ -991,20 +1146,6 @@ class DomainDataFull(DomainExport):
], ],
) )
@classmethod
def get_computed_fields(cls, delimiter=", ", **kwargs):
"""
Get a dict of computed fields.
"""
return {
"so_name": Concat(
Coalesce(F("senior_official__first_name"), Value("")),
Value(" "),
Coalesce(F("senior_official__last_name"), Value("")),
output_field=CharField(),
),
}
@classmethod @classmethod
def get_related_table_fields(cls): def get_related_table_fields(cls):
""" """
@ -1038,6 +1179,13 @@ class DomainDataFederal(DomainExport):
"Security contact email", "Security contact email",
] ]
@classmethod
def get_annotations_for_sort(cls, delimiter=", "):
"""
Get a dict of annotations to make available for sorting.
"""
return cls.get_computed_fields()
@classmethod @classmethod
def get_sort_fields(cls): def get_sort_fields(cls):
""" """
@ -1045,9 +1193,9 @@ class DomainDataFederal(DomainExport):
""" """
# Coalesce is used to replace federal_type of None with ZZZZZ # Coalesce is used to replace federal_type of None with ZZZZZ
return [ return [
"organization_type", "converted_generic_org_type",
Coalesce("federal_type", Value("ZZZZZ")), Coalesce("converted_federal_type", Value("ZZZZZ")),
"federal_agency", "converted_federal_agency",
"domain__name", "domain__name",
] ]
@ -1086,20 +1234,6 @@ class DomainDataFederal(DomainExport):
], ],
) )
@classmethod
def get_computed_fields(cls, delimiter=", ", **kwargs):
"""
Get a dict of computed fields.
"""
return {
"so_name": Concat(
Coalesce(F("senior_official__first_name"), Value("")),
Value(" "),
Coalesce(F("senior_official__last_name"), Value("")),
output_field=CharField(),
),
}
@classmethod @classmethod
def get_related_table_fields(cls): def get_related_table_fields(cls):
""" """
@ -1477,24 +1611,180 @@ class DomainRequestExport(BaseExport):
# Return the model class that this export handles # Return the model class that this export handles
return DomainRequest return DomainRequest
def get_filtered_domain_requests_by_org(domain_requests_to_filter, org_to_filter_by):
"""Returns a list of Domain Requests that has been filtered by the given organization value"""
annotated_queryset = domain_requests_to_filter.annotate(
converted_generic_org_type=Case(
# Recreate the logic of the converted_generic_org_type property
# here in annotations
When(portfolio__isnull=False, then=F("portfolio__organization_type")),
default=F("generic_org_type"),
output_field=CharField(),
)
)
return annotated_queryset.filter(converted_generic_org_type=org_to_filter_by)
# return domain_requests_to_filter.filter(
# # Filter based on the generic org value returned by converted_generic_org_type
# id__in=[
# domainRequest.id
# for domainRequest in domain_requests_to_filter
# if domainRequest.converted_generic_org_type
# and domainRequest.converted_generic_org_type == org_to_filter_by
# ]
# )
@classmethod
def get_computed_fields(cls, delimiter=", ", **kwargs):
"""
Get a dict of computed fields.
"""
# NOTE: These computed fields imitate @Property functions in the Domain model and Portfolio model where needed.
# This is for performance purposes. Since we are working with dictionary values and not
# model objects as we export data, trying to reinstate model objects in order to grab @property
# values negatively impacts performance. Therefore, we will follow best practice and use annotations
return {
"converted_generic_org_type": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__organization_type")),
# Otherwise, return the natively assigned value
default=F("generic_org_type"),
output_field=CharField(),
),
"converted_federal_agency": Case(
# When portfolio is present, use its value instead
When(
Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False),
then=F("portfolio__federal_agency__agency"),
),
# Otherwise, return the natively assigned value
default=F("federal_agency__agency"),
output_field=CharField(),
),
"converted_federal_type": Case(
# When portfolio is present, use its value instead
# NOTE: this is an @Property funciton in portfolio.
When(
Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False),
then=F("portfolio__federal_agency__federal_type"),
),
# Otherwise, return the natively assigned value
default=F("federal_type"),
output_field=CharField(),
),
"converted_organization_name": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__organization_name")),
# Otherwise, return the natively assigned value
default=F("organization_name"),
output_field=CharField(),
),
"converted_city": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__city")),
# Otherwise, return the natively assigned value
default=F("city"),
output_field=CharField(),
),
"converted_state_territory": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__state_territory")),
# Otherwise, return the natively assigned value
default=F("state_territory"),
output_field=CharField(),
),
"converted_so_email": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__email")),
# Otherwise, return the natively assigned senior official
default=F("senior_official__email"),
output_field=CharField(),
),
"converted_senior_official_last_name": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__last_name")),
# Otherwise, return the natively assigned senior official
default=F("senior_official__last_name"),
output_field=CharField(),
),
"converted_senior_official_first_name": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__first_name")),
# Otherwise, return the natively assigned senior official
default=F("senior_official__first_name"),
output_field=CharField(),
),
"converted_senior_official_title": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__title")),
# Otherwise, return the natively assigned senior official
default=F("senior_official__title"),
output_field=CharField(),
),
"converted_so_name": Case(
# When portfolio is present, use that senior official instead
When(
Q(portfolio__isnull=False) & Q(portfolio__senior_official__isnull=False),
then=Concat(
Coalesce(F("portfolio__senior_official__first_name"), Value("")),
Value(" "),
Coalesce(F("portfolio__senior_official__last_name"), Value("")),
output_field=CharField(),
),
),
# Otherwise, return the natively assigned senior official
default=Concat(
Coalesce(F("senior_official__first_name"), Value("")),
Value(" "),
Coalesce(F("senior_official__last_name"), Value("")),
output_field=CharField(),
),
output_field=CharField(),
),
}
@classmethod @classmethod
def get_sliced_requests(cls, filter_condition): def get_sliced_requests(cls, filter_condition):
"""Get filtered requests counts sliced by org type and election office.""" """Get filtered requests counts sliced by org type and election office."""
requests = DomainRequest.objects.all().filter(**filter_condition).distinct() requests = DomainRequest.objects.all().filter(**filter_condition).distinct()
requests_count = requests.count() requests_count = requests.count()
federal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count() federal = (
interstate = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).distinct().count() cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.FEDERAL)
state_or_territory = ( .distinct()
requests.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count() .count()
)
interstate = (
cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.INTERSTATE)
.distinct()
.count()
)
state_or_territory = (
cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.STATE_OR_TERRITORY)
.distinct()
.count()
)
tribal = (
cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.TRIBAL)
.distinct()
.count()
)
county = (
cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.COUNTY)
.distinct()
.count()
)
city = (
cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.CITY).distinct().count()
) )
tribal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count()
county = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count()
city = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count()
special_district = ( special_district = (
requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count() cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.SPECIAL_DISTRICT)
.distinct()
.count()
) )
school_district = ( school_district = (
requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.SCHOOL_DISTRICT)
.distinct()
.count()
) )
election_board = requests.filter(is_election_board=True).distinct().count() election_board = requests.filter(is_election_board=True).distinct().count()
@ -1518,11 +1808,11 @@ class DomainRequestExport(BaseExport):
""" """
# Handle the federal_type field. Defaults to the wrong format. # Handle the federal_type field. Defaults to the wrong format.
federal_type = model.get("federal_type") federal_type = model.get("converted_federal_type")
human_readable_federal_type = BranchChoices.get_branch_label(federal_type) if federal_type else None human_readable_federal_type = BranchChoices.get_branch_label(federal_type) if federal_type else None
# Handle the org_type field # Handle the org_type field
org_type = model.get("generic_org_type") or model.get("organization_type") org_type = model.get("converted_generic_org_type")
human_readable_org_type = DomainRequest.OrganizationChoices.get_org_label(org_type) if org_type else None human_readable_org_type = DomainRequest.OrganizationChoices.get_org_label(org_type) if org_type else None
# Handle the status field. Defaults to the wrong format. # Handle the status field. Defaults to the wrong format.
@ -1570,19 +1860,19 @@ class DomainRequestExport(BaseExport):
"Other contacts": model.get("all_other_contacts"), "Other contacts": model.get("all_other_contacts"),
"Current websites": model.get("all_current_websites"), "Current websites": model.get("all_current_websites"),
# Untouched FK fields - passed into the request dict. # Untouched FK fields - passed into the request dict.
"Federal agency": model.get("federal_agency__agency"), "Federal agency": model.get("converted_federal_agency"),
"SO first name": model.get("senior_official__first_name"), "SO first name": model.get("converted_senior_official_first_name"),
"SO last name": model.get("senior_official__last_name"), "SO last name": model.get("converted_senior_official_last_name"),
"SO email": model.get("senior_official__email"), "SO email": model.get("converted_so_email"),
"SO title/role": model.get("senior_official__title"), "SO title/role": model.get("converted_senior_official_title"),
"Creator first name": model.get("creator__first_name"), "Creator first name": model.get("creator__first_name"),
"Creator last name": model.get("creator__last_name"), "Creator last name": model.get("creator__last_name"),
"Creator email": model.get("creator__email"), "Creator email": model.get("creator__email"),
"Investigator": model.get("investigator__email"), "Investigator": model.get("investigator__email"),
# Untouched fields # Untouched fields
"Organization name": model.get("organization_name"), "Organization name": model.get("converted_organization_name"),
"City": model.get("city"), "City": model.get("converted_city"),
"State/territory": model.get("state_territory"), "State/territory": model.get("converted_state_territory"),
"Request purpose": model.get("purpose"), "Request purpose": model.get("purpose"),
"CISA regional representative": model.get("cisa_representative_email"), "CISA regional representative": model.get("cisa_representative_email"),
"Last submitted date": model.get("last_submitted_date"), "Last submitted date": model.get("last_submitted_date"),
@ -1725,24 +2015,34 @@ class DomainRequestDataFull(DomainRequestExport):
""" """
Get a dict of computed fields. Get a dict of computed fields.
""" """
return { # Get computed fields from the parent class
"creator_approved_domains_count": cls.get_creator_approved_domains_count_query(), computed_fields = super().get_computed_fields()
"creator_active_requests_count": cls.get_creator_active_requests_count_query(),
"all_current_websites": StringAgg("current_websites__website", delimiter=delimiter, distinct=True), # Add additional computed fields
"all_alternative_domains": StringAgg("alternative_domains__website", delimiter=delimiter, distinct=True), computed_fields.update(
# Coerce the other contacts object to "{first_name} {last_name} {email}" {
"all_other_contacts": StringAgg( "creator_approved_domains_count": cls.get_creator_approved_domains_count_query(),
Concat( "creator_active_requests_count": cls.get_creator_active_requests_count_query(),
"other_contacts__first_name", "all_current_websites": StringAgg("current_websites__website", delimiter=delimiter, distinct=True),
Value(" "), "all_alternative_domains": StringAgg(
"other_contacts__last_name", "alternative_domains__website", delimiter=delimiter, distinct=True
Value(" "),
"other_contacts__email",
), ),
delimiter=delimiter, # Coerce the other contacts object to "{first_name} {last_name} {email}"
distinct=True, "all_other_contacts": StringAgg(
), Concat(
} "other_contacts__first_name",
Value(" "),
"other_contacts__last_name",
Value(" "),
"other_contacts__email",
),
delimiter=delimiter,
distinct=True,
),
}
)
return computed_fields
@classmethod @classmethod
def get_related_table_fields(cls): def get_related_table_fields(cls):

View file

@ -13,6 +13,7 @@ from .domain import (
DomainAddUserView, DomainAddUserView,
DomainInvitationCancelView, DomainInvitationCancelView,
DomainDeleteUserView, DomainDeleteUserView,
PrototypeDomainDNSRecordView,
) )
from .user_profile import UserProfileView, FinishProfileSetupView from .user_profile import UserProfileView, FinishProfileSetupView
from .health import * from .health import *

View file

@ -7,7 +7,7 @@ inherit from `DomainPermissionView` (or DomainInvitationPermissionCancelView).
from datetime import date from datetime import date
import logging import logging
import requests
from django.contrib import messages from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.db import IntegrityError from django.db import IntegrityError
@ -64,6 +64,7 @@ from epplibwrapper import (
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionCancelView from .utility import DomainPermissionView, DomainInvitationPermissionCancelView
from django import forms
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -454,6 +455,216 @@ class DomainDNSView(DomainBaseView):
"""DNS Information View.""" """DNS Information View."""
template_name = "domain_dns.html" template_name = "domain_dns.html"
valid_domains = ["igorville.gov", "domainops.gov", "dns.gov"]
def get_context_data(self, **kwargs):
"""Adds custom context."""
context = super().get_context_data(**kwargs)
context["dns_prototype_flag"] = flag_is_active_for_user(self.request.user, "dns_prototype_flag")
context["is_valid_domain"] = self.object.name in self.valid_domains
return context
class PrototypeDomainDNSRecordForm(forms.Form):
"""Form for adding DNS records in prototype."""
name = forms.CharField(label="DNS record name (A record)", required=True, help_text="DNS record name")
content = forms.GenericIPAddressField(
label="IPv4 Address",
required=True,
protocol="IPv4",
)
ttl = forms.ChoiceField(
label="TTL",
choices=[
(1, "Automatic"),
(60, "1 minute"),
(300, "5 minutes"),
(1800, "30 minutes"),
(3600, "1 hour"),
(7200, "2 hours"),
(18000, "5 hours"),
(43200, "12 hours"),
(86400, "1 day"),
],
initial=1,
)
class PrototypeDomainDNSRecordView(DomainFormBaseView):
template_name = "prototype_domain_dns.html"
form_class = PrototypeDomainDNSRecordForm
valid_domains = ["igorville.gov", "domainops.gov", "dns.gov"]
def has_permission(self):
has_permission = super().has_permission()
if not has_permission:
return False
flag_enabled = flag_is_active_for_user(self.request.user, "dns_prototype_flag")
if not flag_enabled:
return False
self.object = self.get_object()
if self.object.name not in self.valid_domains:
return False
return True
def get_success_url(self):
return reverse("prototype-domain-dns", kwargs={"pk": self.object.pk})
def find_by_name(self, items, name):
"""Find an item by name in a list of dictionaries."""
return next((item.get("id") for item in items if item.get("name") == name), None)
def post(self, request, *args, **kwargs):
"""Handle form submission."""
self.object = self.get_object()
form = self.get_form()
errors = []
if form.is_valid():
try:
if settings.IS_PRODUCTION and self.object.name != "igorville.gov":
raise Exception(f"create dns record was called for domain {self.name}")
if not settings.IS_PRODUCTION and self.object.name not in self.valid_domains:
raise Exception(
f"Can only create DNS records for: {self.valid_domains}."
" Create one in a test environment if it doesn't already exist."
)
base_url = "https://api.cloudflare.com/client/v4"
headers = {
"X-Auth-Email": settings.SECRET_REGISTRY_SERVICE_EMAIL,
"X-Auth-Key": settings.SECRET_REGISTRY_TENANT_KEY,
"Content-Type": "application/json",
}
params = {"tenant_name": settings.SECRET_REGISTRY_TENANT_NAME}
# 1. Get tenant details
tenant_response = requests.get(f"{base_url}/user/tenants", headers=headers, params=params, timeout=5)
tenant_response_json = tenant_response.json()
logger.info(f"Found tenant: {tenant_response_json}")
tenant_id = tenant_response_json["result"][0]["tenant_tag"]
errors = tenant_response_json.get("errors", [])
tenant_response.raise_for_status()
# 2. Create or get a account under tenant
# Check to see if the account already exists. Filters accounts by tenant_id / account_name.
account_name = f"account-{self.object.name}"
params = {"tenant_id": tenant_id, "name": account_name}
account_response = requests.get(f"{base_url}/accounts", headers=headers, params=params, timeout=5)
account_response_json = account_response.json()
logger.debug(f"account get: {account_response_json}")
errors = account_response_json.get("errors", [])
account_response.raise_for_status()
# See if we already made an account.
# This maybe doesn't need to be a for loop (1 record or 0) but alas, here we are
accounts = account_response_json.get("result", [])
account_id = self.find_by_name(accounts, account_name)
# If we didn't, create one
if not account_id:
account_response = requests.post(
f"{base_url}/accounts",
headers=headers,
json={"name": account_name, "type": "enterprise", "unit": {"id": tenant_id}},
timeout=5,
)
account_response_json = account_response.json()
logger.info(f"Created account: {account_response_json}")
account_id = account_response_json["result"]["id"]
errors = account_response_json.get("errors", [])
account_response.raise_for_status()
# 3. Create or get a zone under account
# Try to find an existing zone first by searching on the current id
zone_name = self.object.name
params = {"account.id": account_id, "name": zone_name}
zone_response = requests.get(f"{base_url}/zones", headers=headers, params=params, timeout=5)
zone_response_json = zone_response.json()
logger.debug(f"get zone: {zone_response_json}")
errors = zone_response_json.get("errors", [])
zone_response.raise_for_status()
# Get the zone id
zones = zone_response_json.get("result", [])
zone_id = self.find_by_name(zones, zone_name)
# Create one if it doesn't presently exist
if not zone_id:
zone_response = requests.post(
f"{base_url}/zones",
headers=headers,
json={"name": zone_name, "account": {"id": account_id}, "type": "full"},
timeout=5,
)
zone_response_json = zone_response.json()
logger.info(f"Created zone: {zone_response_json}")
zone_id = zone_response_json.get("result", {}).get("id")
errors = zone_response_json.get("errors", [])
zone_response.raise_for_status()
# 4. Add or get a zone subscription
# See if one already exists
subscription_response = requests.get(
f"{base_url}/zones/{zone_id}/subscription", headers=headers, timeout=5
)
subscription_response_json = subscription_response.json()
logger.debug(f"get subscription: {subscription_response_json}")
# Create a subscription if one doesn't exist already.
# If it doesn't, we get this error message (code 1207):
# Add a core subscription first and try again. The zone does not have an active core subscription.
# Note that status code and error code are different here.
if subscription_response.status_code == 404:
subscription_response = requests.post(
f"{base_url}/zones/{zone_id}/subscription",
headers=headers,
json={"rate_plan": {"id": "PARTNERS_ENT"}, "frequency": "annual"},
timeout=5,
)
subscription_response.raise_for_status()
subscription_response_json = subscription_response.json()
logger.info(f"Created subscription: {subscription_response_json}")
else:
subscription_response.raise_for_status()
# # 5. Create DNS record
# # Format the DNS record according to Cloudflare's API requirements
dns_response = requests.post(
f"{base_url}/zones/{zone_id}/dns_records",
headers=headers,
json={
"type": "A",
"name": form.cleaned_data["name"],
"content": form.cleaned_data["content"],
"ttl": int(form.cleaned_data["ttl"]),
"comment": "Test record (will need clean up)",
},
timeout=5,
)
dns_response_json = dns_response.json()
logger.info(f"Created DNS record: {dns_response_json}")
errors = dns_response_json.get("errors", [])
dns_response.raise_for_status()
dns_name = dns_response_json["result"]["name"]
messages.success(request, f"DNS A record '{dns_name}' created successfully.")
except Exception as err:
logger.error(f"Error creating DNS A record for {self.object.name}: {err}")
messages.error(request, f"An error occurred: {err}")
finally:
if errors:
messages.error(request, f"Request errors: {errors}")
return super().post(request)
class DomainNameserversView(DomainFormBaseView): class DomainNameserversView(DomainFormBaseView):

View file

@ -53,7 +53,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
URL_NAMESPACE = "domain-request" URL_NAMESPACE = "domain-request"
# name for accessing /domain-request/<id>/edit # name for accessing /domain-request/<id>/edit
EDIT_URL_NAME = "edit-domain-request" EDIT_URL_NAME = "edit-domain-request"
NEW_URL_NAME = "/request/start/" NEW_URL_NAME = "start"
FINISHED_URL_NAME = "finished"
# region: Titles # region: Titles
# We need to pass our human-readable step titles as context to the templates. # We need to pass our human-readable step titles as context to the templates.
@ -313,7 +314,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# send users "to the domain request wizard" without needing to know which view # send users "to the domain request wizard" without needing to know which view
# is first in the list of steps. # is first in the list of steps.
if self.__class__ == DomainRequestWizard: if self.__class__ == DomainRequestWizard:
if request.path_info == self.NEW_URL_NAME: if current_url == self.NEW_URL_NAME:
# Clear context so the prop getter won't create a request here. # Clear context so the prop getter won't create a request here.
# Creating a request will be handled in the post method for the # Creating a request will be handled in the post method for the
# intro page. # intro page.
@ -614,7 +615,7 @@ class RequestingEntity(DomainRequestWizard):
class PortfolioAdditionalDetails(DomainRequestWizard): class PortfolioAdditionalDetails(DomainRequestWizard):
template_name = "portfolio_domain_request_additional_details.html" template_name = "portfolio_domain_request_additional_details.html"
forms = [forms.AnythingElseForm] forms = [forms.PortfolioAnythingElseForm]
# Non-portfolio pages # Non-portfolio pages

View file

@ -1,4 +1,5 @@
import logging import logging
from django.db import models
from django.http import JsonResponse from django.http import JsonResponse
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -28,11 +29,12 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View):
objects = self.apply_search(objects, request) objects = self.apply_search(objects, request)
objects = self.apply_sorting(objects, request) objects = self.apply_sorting(objects, request)
paginator = Paginator(objects, 10) paginator = Paginator(objects, self.get_page_size(request))
page_number = request.GET.get("page") page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
domains = [self.serialize_domain(domain, request.user) for domain in page_obj.object_list] member_id = request.GET.get("member_id")
domains = [self.serialize_domain(domain, member_id, request.user) for domain in page_obj.object_list]
return JsonResponse( return JsonResponse(
{ {
@ -46,6 +48,23 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View):
} }
) )
def get_page_size(self, request):
"""Gets the page size.
If member_only, need to return the entire result set every time, so need
to set to a very large page size. If not member_only, this can be adjusted
to provide a smaller page size"""
member_only = request.GET.get("member_only", "false").lower() in ["true", "1"]
if member_only:
# This number needs to remain very high as the entire result set
# must be returned when member_only
return 1000
else:
# This number can be adjusted if we want to add pagination to the result page
# later
return 1000
def get_domain_ids_from_request(self, request): def get_domain_ids_from_request(self, request):
"""Get domain ids from request. """Get domain ids from request.
@ -86,13 +105,41 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View):
return queryset return queryset
def apply_sorting(self, queryset, request): def apply_sorting(self, queryset, request):
# Get the sorting parameters from the request
sort_by = request.GET.get("sort_by", "name") sort_by = request.GET.get("sort_by", "name")
order = request.GET.get("order", "asc") order = request.GET.get("order", "asc")
if order == "desc": # Sort by 'checked' if specified, otherwise by the given field
sort_by = f"-{sort_by}" if sort_by == "checked":
return queryset.order_by(sort_by) # Get list of checked ids from the request
checked_ids = request.GET.get("checkedDomainIds")
if checked_ids:
# Split the comma-separated string into a list of integers
checked_ids = [int(id.strip()) for id in checked_ids.split(",") if id.strip().isdigit()]
else:
# If no value is passed, set checked_ids to an empty list
checked_ids = []
# Annotate each object with a 'checked' value based on whether its ID is in checkedIds
queryset = queryset.annotate(
checked=models.Case(
models.When(id__in=checked_ids, then=models.Value(True)),
default=models.Value(False),
output_field=models.BooleanField(),
)
)
# Add ordering logic for 'checked'
if order == "desc":
queryset = queryset.order_by("-checked", "name")
else:
queryset = queryset.order_by("checked", "name")
else:
# Handle other fields as normal
if order == "desc":
sort_by = f"-{sort_by}"
queryset = queryset.order_by(sort_by)
def serialize_domain(self, domain, user): return queryset
def serialize_domain(self, domain, member_id, user):
suborganization_name = None suborganization_name = None
try: try:
domain_info = domain.domain_info domain_info = domain.domain_info
@ -107,9 +154,22 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View):
# Check if there is a UserDomainRole for this domain and user # Check if there is a UserDomainRole for this domain and user
user_domain_role_exists = UserDomainRole.objects.filter(domain_id=domain.id, user=user).exists() user_domain_role_exists = UserDomainRole.objects.filter(domain_id=domain.id, user=user).exists()
view_only = not user_domain_role_exists or domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] view_only = not user_domain_role_exists or domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD]
# Check if the specified member is the only member assigned as manager of domain
only_member_assigned_to_domain = False
if member_id:
member_domain_role_count = UserDomainRole.objects.filter(
domain_id=domain.id, role=UserDomainRole.Roles.MANAGER
).count()
member_domain_role_exists = UserDomainRole.objects.filter(
domain_id=domain.id, user_id=member_id, role=UserDomainRole.Roles.MANAGER
).exists()
only_member_assigned_to_domain = member_domain_role_exists and member_domain_role_count == 1
return { return {
"id": domain.id, "id": domain.id,
"name": domain.name, "name": domain.name,
"member_is_only_manager": only_member_assigned_to_domain,
"expiration_date": domain.expiration_date, "expiration_date": domain.expiration_date,
"state": domain.state, "state": domain.state,
"state_display": domain.state_display(), "state_display": domain.state_display(),

View file

@ -20,6 +20,7 @@ from registrar.views.utility.permission_views import (
PortfolioBasePermissionView, PortfolioBasePermissionView,
NoPortfolioDomainsPermissionView, NoPortfolioDomainsPermissionView,
PortfolioMemberDomainsPermissionView, PortfolioMemberDomainsPermissionView,
PortfolioMemberDomainsEditPermissionView,
PortfolioMemberEditPermissionView, PortfolioMemberEditPermissionView,
PortfolioMemberPermissionView, PortfolioMemberPermissionView,
PortfolioMembersPermissionView, PortfolioMembersPermissionView,
@ -198,6 +199,24 @@ class PortfolioMemberDomainsView(PortfolioMemberDomainsPermissionView, View):
) )
class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, View):
template_name = "portfolio_member_domains_edit.html"
def get(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
member = portfolio_permission.user
return render(
request,
self.template_name,
{
"portfolio_permission": portfolio_permission,
"member": member,
},
)
class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View): class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View):
template_name = "portfolio_member.html" template_name = "portfolio_member.html"
@ -307,6 +326,22 @@ class PortfolioInvitedMemberDomainsView(PortfolioMemberDomainsPermissionView, Vi
) )
class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, View):
template_name = "portfolio_member_domains_edit.html"
def get(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
return render(
request,
self.template_name,
{
"portfolio_invitation": portfolio_invitation,
},
)
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domains. """Some users have access to the underlying portfolio, but not any domains.
This is a custom view which explains that to the user - and denotes who to contact. This is a custom view which explains that to the user - and denotes who to contact.
@ -626,34 +661,3 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin):
if permission_exists: if permission_exists:
messages.warning(self.request, "User is already a member of this portfolio.") messages.warning(self.request, "User is already a member of this portfolio.")
return redirect(self.get_success_url()) return redirect(self.get_success_url())
# look up a user with that email
try:
requested_user = User.objects.get(email=requested_email)
except User.DoesNotExist:
# no matching user, go make an invitation
return self._make_invitation(requested_email, requestor)
else:
# If user already exists, check to see if they are part of the portfolio already
# If they are already part of the portfolio, raise an error. Otherwise, send an invite.
existing_user = UserPortfolioPermission.objects.get(user=requested_user, portfolio=self.object)
if existing_user:
messages.warning(self.request, "User is already a member of this portfolio.")
else:
try:
self._send_portfolio_invitation_email(requested_email, requestor, add_success=False)
except EmailSendingError:
logger.warn(
"Could not send email invitation (EmailSendingError)",
self.object,
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
except Exception:
logger.warn(
"Could not send email invitation (Other Exception)",
self.object,
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
return redirect(self.get_success_url())

View file

@ -572,3 +572,20 @@ class PortfolioMemberDomainsPermission(PortfolioBasePermission):
return False return False
return super().has_permission() return super().has_permission()
class PortfolioMemberDomainsEditPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio member or invited member domains edit pages if user
has access to edit, otherwise 403"""
def has_permission(self):
"""Check if this user has access to member or invited member domains for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False
return super().has_permission()

View file

@ -16,6 +16,7 @@ from .mixins import (
PortfolioDomainRequestsPermission, PortfolioDomainRequestsPermission,
PortfolioDomainsPermission, PortfolioDomainsPermission,
PortfolioMemberDomainsPermission, PortfolioMemberDomainsPermission,
PortfolioMemberDomainsEditPermission,
PortfolioMemberEditPermission, PortfolioMemberEditPermission,
UserDeleteDomainRolePermission, UserDeleteDomainRolePermission,
UserProfilePermission, UserProfilePermission,
@ -279,3 +280,13 @@ class PortfolioMemberDomainsPermissionView(PortfolioMemberDomainsPermission, Por
This abstract view cannot be instantiated. Actual views must specify This abstract view cannot be instantiated. Actual views must specify
`template_name`. `template_name`.
""" """
class PortfolioMemberDomainsEditPermissionView(
PortfolioMemberDomainsEditPermission, PortfolioBasePermissionView, abc.ABC
):
"""Abstract base view for portfolio member domains edit views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""

View file

@ -74,6 +74,7 @@
10038 OUTOFSCOPE http://app:8080/permissions 10038 OUTOFSCOPE http://app:8080/permissions
10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/suborganization/
10038 OUTOFSCOPE http://app:8080/transfer/ 10038 OUTOFSCOPE http://app:8080/transfer/
10038 OUTOFSCOPE http://app:8080/prototype-dns
# This URL always returns 404, so include it as well. # This URL always returns 404, so include it as well.
10038 OUTOFSCOPE http://app:8080/todo 10038 OUTOFSCOPE http://app:8080/todo
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers