mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-06 01:35:22 +02:00
merge main
This commit is contained in:
commit
350557a7a6
38 changed files with 1334 additions and 353 deletions
1
.github/workflows/deploy-sandbox.yaml
vendored
1
.github/workflows/deploy-sandbox.yaml
vendored
|
@ -27,6 +27,7 @@ jobs:
|
|||
|| startsWith(github.head_ref, 'cb/')
|
||||
|| startsWith(github.head_ref, 'hotgov/')
|
||||
|| startsWith(github.head_ref, 'litterbox/')
|
||||
|| startsWith(github.head_ref, 'ag/')
|
||||
outputs:
|
||||
environment: ${{ steps.var.outputs.environment}}
|
||||
runs-on: "ubuntu-latest"
|
||||
|
|
1
.github/workflows/migrate.yaml
vendored
1
.github/workflows/migrate.yaml
vendored
|
@ -16,6 +16,7 @@ on:
|
|||
- stable
|
||||
- staging
|
||||
- development
|
||||
- ag
|
||||
- litterbox
|
||||
- hotgov
|
||||
- cb
|
||||
|
|
1
.github/workflows/reset-db.yaml
vendored
1
.github/workflows/reset-db.yaml
vendored
|
@ -16,6 +16,7 @@ on:
|
|||
options:
|
||||
- staging
|
||||
- development
|
||||
- ag
|
||||
- litterbox
|
||||
- hotgov
|
||||
- cb
|
||||
|
|
|
@ -32,9 +32,11 @@ For reference, the zip file will contain the following tables in csv form:
|
|||
* DomainInformation
|
||||
* DomainUserRole
|
||||
* DraftDomain
|
||||
* FederalAgency
|
||||
* Websites
|
||||
* Host
|
||||
* HostIP
|
||||
* PublicContact
|
||||
|
||||
After exporting the file from the target environment, scp the exported_tables.zip
|
||||
file from the target environment to local. Run the below commands from local.
|
||||
|
@ -75,17 +77,25 @@ For reference, this deletes all rows from the following tables:
|
|||
* DomainInformation
|
||||
* DomainRequest
|
||||
* Domain
|
||||
* User (all but the current user)
|
||||
* User
|
||||
* Contact
|
||||
* Websites
|
||||
* DraftDomain
|
||||
* HostIP
|
||||
* Host
|
||||
* PublicContact
|
||||
* FederalAgency
|
||||
|
||||
#### Importing into Target Environment
|
||||
|
||||
Once target environment is prepared, files can be imported.
|
||||
|
||||
If importing tables from stable environment into an OT&E sandbox, there will be a difference
|
||||
between the stable's registry and the sandbox's registry. Therefore, you need to run import_tables
|
||||
with --skipEppSave option set to False. If you set to False, it will attempt to save PublicContact
|
||||
records to the registry on load. If this is unset, or set to True, it will load the database and not
|
||||
attempt to update the registry on load.
|
||||
|
||||
To scp the exported_tables.zip file from local to the sandbox, run the following:
|
||||
|
||||
Get passcode by running:
|
||||
|
@ -107,7 +117,7 @@ cf ssh {target-app}
|
|||
example cleaning getgov-backup:
|
||||
cf ssh getgov-backup
|
||||
/tmp/lifecycle/backup
|
||||
./manage.py import_tables
|
||||
./manage.py import_tables --no-skipEppSave
|
||||
|
||||
For reference, this imports tables in the following order:
|
||||
|
||||
|
@ -118,9 +128,11 @@ For reference, this imports tables in the following order:
|
|||
* HostIP
|
||||
* DraftDomain
|
||||
* Websites
|
||||
* FederalAgency
|
||||
* DomainRequest
|
||||
* DomainInformation
|
||||
* UserDomainRole
|
||||
* PublicContact
|
||||
|
||||
Optional step:
|
||||
* Run fixtures to load fixture users back in
|
32
ops/manifests/manifest-ag.yaml
Normal file
32
ops/manifests/manifest-ag.yaml
Normal file
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
applications:
|
||||
- name: getgov-ag
|
||||
buildpacks:
|
||||
- python_buildpack
|
||||
path: ../../src
|
||||
instances: 1
|
||||
memory: 512M
|
||||
stack: cflinuxfs4
|
||||
timeout: 180
|
||||
command: ./run.sh
|
||||
health-check-type: http
|
||||
health-check-http-endpoint: /health
|
||||
health-check-invocation-timeout: 40
|
||||
env:
|
||||
# Send stdout and stderr straight to the terminal without buffering
|
||||
PYTHONUNBUFFERED: yup
|
||||
# Tell Django where to find its configuration
|
||||
DJANGO_SETTINGS_MODULE: registrar.config.settings
|
||||
# Tell Django where it is being hosted
|
||||
DJANGO_BASE_URL: https://getgov-ag.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
DJANGO_LOG_LEVEL: INFO
|
||||
# default public site location
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
IS_PRODUCTION: False
|
||||
routes:
|
||||
- route: getgov-ag.app.cloud.gov
|
||||
services:
|
||||
- getgov-credentials
|
||||
- getgov-ag-database
|
|
@ -1233,7 +1233,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
search_help_text = "Search by domain."
|
||||
|
||||
fieldsets = [
|
||||
(None, {"fields": ["creator", "submitter", "domain_request", "notes"]}),
|
||||
(None, {"fields": ["portfolio", "creator", "submitter", "domain_request", "notes"]}),
|
||||
(".gov domain", {"fields": ["domain"]}),
|
||||
("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}),
|
||||
("Background info", {"fields": ["anything_else"]}),
|
||||
|
@ -1319,6 +1319,32 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
change_form_template = "django/admin/domain_information_change_form.html"
|
||||
|
||||
superuser_only_fields = [
|
||||
"portfolio",
|
||||
]
|
||||
|
||||
# DEVELOPER's NOTE:
|
||||
# Normally, to exclude a field from an Admin form, we could simply utilize
|
||||
# Django's "exclude" feature. However, it causes a "missing key" error if we
|
||||
# go that route for this particular form. The error gets thrown by our
|
||||
# custom fieldset.html code and is due to the fact that "exclude" removes
|
||||
# fields from base_fields but not fieldsets. Rather than reworking our
|
||||
# custom frontend, it seems more straightforward (and easier to read) to simply
|
||||
# modify the fieldsets list so that it excludes any fields we want to remove
|
||||
# based on permissions (eg. superuser_only_fields) or other conditions.
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = self.fieldsets
|
||||
|
||||
# Create a modified version of fieldsets to exclude certain fields
|
||||
if not request.user.has_perm("registrar.full_access_permission"):
|
||||
modified_fieldsets = []
|
||||
for name, data in fieldsets:
|
||||
fields = data.get("fields", [])
|
||||
fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields)
|
||||
modified_fieldsets.append((name, {"fields": fields}))
|
||||
return modified_fieldsets
|
||||
return fieldsets
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Set the read-only state on form elements.
|
||||
We have 1 conditions that determine which fields are read-only:
|
||||
|
@ -1482,6 +1508,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
None,
|
||||
{
|
||||
"fields": [
|
||||
"portfolio",
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"action_needed_reason",
|
||||
|
@ -1592,6 +1619,32 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
]
|
||||
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
|
||||
|
||||
superuser_only_fields = [
|
||||
"portfolio",
|
||||
]
|
||||
|
||||
# DEVELOPER's NOTE:
|
||||
# Normally, to exclude a field from an Admin form, we could simply utilize
|
||||
# Django's "exclude" feature. However, it causes a "missing key" error if we
|
||||
# go that route for this particular form. The error gets thrown by our
|
||||
# custom fieldset.html code and is due to the fact that "exclude" removes
|
||||
# fields from base_fields but not fieldsets. Rather than reworking our
|
||||
# custom frontend, it seems more straightforward (and easier to read) to simply
|
||||
# modify the fieldsets list so that it excludes any fields we want to remove
|
||||
# based on permissions (eg. superuser_only_fields) or other conditions.
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = super().get_fieldsets(request, obj)
|
||||
|
||||
# Create a modified version of fieldsets to exclude certain fields
|
||||
if not request.user.has_perm("registrar.full_access_permission"):
|
||||
modified_fieldsets = []
|
||||
for name, data in fieldsets:
|
||||
fields = data.get("fields", [])
|
||||
fields = tuple(field for field in fields if field not in self.superuser_only_fields)
|
||||
modified_fieldsets.append((name, {"fields": fields}))
|
||||
return modified_fieldsets
|
||||
return fieldsets
|
||||
|
||||
# Table ordering
|
||||
# NOTE: This impacts the select2 dropdowns (combobox)
|
||||
# Currentl, there's only one for requests on DomainInfo
|
||||
|
@ -1937,13 +1990,7 @@ class DomainInformationInline(admin.StackedInline):
|
|||
template = "django/admin/includes/domain_info_inline_stacked.html"
|
||||
model = models.DomainInformation
|
||||
|
||||
fieldsets = copy.deepcopy(DomainInformationAdmin.fieldsets)
|
||||
# remove .gov domain from fieldset
|
||||
for index, (title, f) in enumerate(fieldsets):
|
||||
if title == ".gov domain":
|
||||
del fieldsets[index]
|
||||
break
|
||||
|
||||
fieldsets = DomainInformationAdmin.fieldsets
|
||||
readonly_fields = DomainInformationAdmin.readonly_fields
|
||||
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
|
||||
|
||||
|
@ -1990,6 +2037,23 @@ class DomainInformationInline(admin.StackedInline):
|
|||
def get_readonly_fields(self, request, obj=None):
|
||||
return DomainInformationAdmin.get_readonly_fields(self, request, obj=None)
|
||||
|
||||
# Re-route the get_fieldsets method to utilize DomainInformationAdmin.get_fieldsets
|
||||
# since that has all the logic for excluding certain fields according to user permissions.
|
||||
# Then modify the remaining fields to further trim out any we don't want for this inline
|
||||
# form
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
# Grab fieldsets from DomainInformationAdmin so that it handles all logic
|
||||
# for permission-based field visibility.
|
||||
modified_fieldsets = DomainInformationAdmin.get_fieldsets(self, request, obj=None)
|
||||
|
||||
# remove .gov domain from fieldset
|
||||
for index, (title, f) in enumerate(modified_fieldsets):
|
||||
if title == ".gov domain":
|
||||
del modified_fieldsets[index]
|
||||
break
|
||||
|
||||
return modified_fieldsets
|
||||
|
||||
|
||||
class DomainResource(FsmModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -2478,16 +2542,35 @@ class PublicContactResource(resources.ModelResource):
|
|||
|
||||
class Meta:
|
||||
model = models.PublicContact
|
||||
# may want to consider these bulk options in future, so left in as comments
|
||||
# use_bulk = True
|
||||
# batch_size = 1000
|
||||
# force_init_instance = True
|
||||
|
||||
def import_row(self, row, instance_loader, using_transactions=True, dry_run=False, raise_errors=None, **kwargs):
|
||||
"""Override kwargs skip_epp_save and set to True"""
|
||||
kwargs["skip_epp_save"] = True
|
||||
return super().import_row(
|
||||
row,
|
||||
instance_loader,
|
||||
using_transactions=using_transactions,
|
||||
dry_run=dry_run,
|
||||
raise_errors=raise_errors,
|
||||
def __init__(self):
|
||||
"""Sets global variables for code tidyness"""
|
||||
super().__init__()
|
||||
self.skip_epp_save = False
|
||||
|
||||
def import_data(
|
||||
self,
|
||||
dataset,
|
||||
dry_run=False,
|
||||
raise_errors=False,
|
||||
use_transactions=None,
|
||||
collect_failed_rows=False,
|
||||
rollback_on_validation_errors=False,
|
||||
**kwargs,
|
||||
):
|
||||
"""Override import_data to set self.skip_epp_save if in kwargs"""
|
||||
self.skip_epp_save = kwargs.get("skip_epp_save", False)
|
||||
return super().import_data(
|
||||
dataset,
|
||||
dry_run,
|
||||
raise_errors,
|
||||
use_transactions,
|
||||
collect_failed_rows,
|
||||
rollback_on_validation_errors,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
@ -2503,7 +2586,7 @@ class PublicContactResource(resources.ModelResource):
|
|||
# we don't have transactions and we want to do a dry_run
|
||||
pass
|
||||
else:
|
||||
instance.save(skip_epp_save=True)
|
||||
instance.save(skip_epp_save=self.skip_epp_save)
|
||||
self.after_save_instance(instance, using_transactions, dry_run)
|
||||
|
||||
|
||||
|
@ -2552,11 +2635,48 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
|
|||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class FederalAgencyAdmin(ListHeaderAdmin):
|
||||
class PortfolioAdmin(ListHeaderAdmin):
|
||||
# NOTE: these are just placeholders. Not part of ACs (haven't been defined yet). Update in future tickets.
|
||||
list_display = ("organization_name", "federal_agency", "creator")
|
||||
search_fields = ["organization_name"]
|
||||
search_help_text = "Search by organization name."
|
||||
# readonly_fields = [
|
||||
# "requestor",
|
||||
# ]
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
|
||||
if obj.creator is not None:
|
||||
# ---- update creator ----
|
||||
# Set the creator field to the current admin user
|
||||
obj.creator = request.user if request.user.is_authenticated else None
|
||||
|
||||
# ---- update organization name ----
|
||||
# org name will be the same as federal agency, if it is federal,
|
||||
# otherwise it will be the actual org name. If nothing is entered for
|
||||
# org name and it is a federal organization, have this field fill with
|
||||
# the federal agency text name.
|
||||
is_federal = obj.organization_type == DomainRequest.OrganizationChoices.FEDERAL
|
||||
if is_federal and obj.organization_name is None:
|
||||
obj.organization_name = obj.federal_agency.agency
|
||||
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class FederalAgencyResource(resources.ModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
import/export file"""
|
||||
|
||||
class Meta:
|
||||
model = models.FederalAgency
|
||||
|
||||
|
||||
class FederalAgencyAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
list_display = ["agency"]
|
||||
search_fields = ["agency"]
|
||||
search_help_text = "Search by agency name."
|
||||
ordering = ["agency"]
|
||||
resource_classes = [FederalAgencyResource]
|
||||
|
||||
|
||||
class UserGroupAdmin(AuditedAdmin):
|
||||
|
@ -2600,6 +2720,14 @@ class WaffleFlagAdmin(FlagAdmin):
|
|||
fields = "__all__"
|
||||
|
||||
|
||||
class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
list_display = ["name", "portfolio"]
|
||||
|
||||
|
||||
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
list_display = ["name", "portfolio"]
|
||||
|
||||
|
||||
admin.site.unregister(LogEntry) # Unregister the default registration
|
||||
|
||||
admin.site.register(LogEntry, CustomLogEntryAdmin)
|
||||
|
@ -2622,6 +2750,9 @@ admin.site.register(models.PublicContact, PublicContactAdmin)
|
|||
admin.site.register(models.DomainRequest, DomainRequestAdmin)
|
||||
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
||||
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
|
||||
admin.site.register(models.Portfolio, PortfolioAdmin)
|
||||
admin.site.register(models.DomainGroup, DomainGroupAdmin)
|
||||
admin.site.register(models.Suborganization, SuborganizationAdmin)
|
||||
|
||||
# Register our custom waffle implementations
|
||||
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
||||
|
|
|
@ -33,6 +33,33 @@ const showElement = (element) => {
|
|||
element.classList.remove('display-none');
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function that scrolls to an element
|
||||
* @param {string} attributeName - The string "class" or "id"
|
||||
* @param {string} attributeValue - The class or id name
|
||||
*/
|
||||
function ScrollToElement(attributeName, attributeValue) {
|
||||
let targetEl = null;
|
||||
|
||||
if (attributeName === 'class') {
|
||||
targetEl = document.getElementsByClassName(attributeValue)[0];
|
||||
} else if (attributeName === 'id') {
|
||||
targetEl = document.getElementById(attributeValue);
|
||||
} else {
|
||||
console.log('Error: unknown attribute name provided.');
|
||||
return; // Exit the function if an invalid attributeName is provided
|
||||
}
|
||||
|
||||
if (targetEl) {
|
||||
const rect = targetEl.getBoundingClientRect();
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
window.scrollTo({
|
||||
top: rect.top + scrollTop,
|
||||
behavior: 'smooth' // Optional: for smooth scrolling
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes an element invisible. */
|
||||
function makeHidden(el) {
|
||||
el.style.position = "absolute";
|
||||
|
@ -895,33 +922,6 @@ function unloadModals() {
|
|||
window.modal.off();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that scrolls to an element
|
||||
* @param {string} attributeName - The string "class" or "id"
|
||||
* @param {string} attributeValue - The class or id name
|
||||
*/
|
||||
function ScrollToElement(attributeName, attributeValue) {
|
||||
let targetEl = null;
|
||||
|
||||
if (attributeName === 'class') {
|
||||
targetEl = document.getElementsByClassName(attributeValue)[0];
|
||||
} else if (attributeName === 'id') {
|
||||
targetEl = document.getElementById(attributeValue);
|
||||
} else {
|
||||
console.log('Error: unknown attribute name provided.');
|
||||
return; // Exit the function if an invalid attributeName is provided
|
||||
}
|
||||
|
||||
if (targetEl) {
|
||||
const rect = targetEl.getBoundingClientRect();
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
window.scrollTo({
|
||||
top: rect.top + scrollTop,
|
||||
behavior: 'smooth' // Optional: for smooth scrolling
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generalized function to update pagination for a list.
|
||||
* @param {string} itemName - The name displayed in the counter
|
||||
|
@ -1294,6 +1294,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
* @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page
|
||||
*/
|
||||
function deleteDomainRequest(domainRequestPk,pageToDisplay) {
|
||||
|
||||
// Use to debug uswds modal issues
|
||||
//console.log('deleteDomainRequest')
|
||||
|
||||
// Get csrf token
|
||||
const csrfToken = getCsrfToken();
|
||||
// Create FormData object and append the CSRF token
|
||||
|
@ -1348,6 +1352,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const tbody = document.querySelector('.domain-requests__table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// Unload modals will re-inject the DOM with the initial placeholders to allow for .on() in regular use cases
|
||||
// We do NOT want that as it will cause multiple placeholders and therefore multiple inits on delete,
|
||||
// which will cause bad delete requests to be sent.
|
||||
const preExistingModalPlaceholders = document.querySelectorAll('[data-placeholder-for^="toggle-delete-domain-alert"]');
|
||||
preExistingModalPlaceholders.forEach(element => {
|
||||
element.remove();
|
||||
});
|
||||
|
||||
// remove any existing modal elements from the DOM so they can be properly re-initialized
|
||||
// after the DOM content changes and there are new delete modal buttons added
|
||||
unloadModals();
|
||||
|
@ -1469,7 +1481,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
`
|
||||
|
||||
domainRequestsSectionWrapper.appendChild(modal);
|
||||
}
|
||||
|
|
|
@ -189,6 +189,7 @@ MIDDLEWARE = [
|
|||
# Used for waffle feature flags
|
||||
"waffle.middleware.WaffleMiddleware",
|
||||
"registrar.registrar_middleware.CheckUserProfileMiddleware",
|
||||
"registrar.registrar_middleware.CheckPortfolioMiddleware",
|
||||
]
|
||||
|
||||
# application object used by Django’s built-in servers (e.g. `runserver`)
|
||||
|
@ -659,6 +660,7 @@ ALLOWED_HOSTS = [
|
|||
"getgov-stable.app.cloud.gov",
|
||||
"getgov-staging.app.cloud.gov",
|
||||
"getgov-development.app.cloud.gov",
|
||||
"getgov-ag.app.cloud.gov",
|
||||
"getgov-litterbox.app.cloud.gov",
|
||||
"getgov-hotgov.app.cloud.gov",
|
||||
"getgov-cb.app.cloud.gov",
|
||||
|
|
|
@ -25,6 +25,7 @@ from registrar.views.domain_request import Step
|
|||
from registrar.views.domain_requests_json import get_domain_requests_json
|
||||
from registrar.views.domains_json import get_domains_json
|
||||
from registrar.views.utility import always_404
|
||||
from registrar.views.portfolios import portfolio_domains, portfolio_domain_requests
|
||||
from api.views import available, get_current_federal, get_current_full
|
||||
|
||||
|
||||
|
@ -58,6 +59,16 @@ for step, view in [
|
|||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="home"),
|
||||
path(
|
||||
"portfolio/<int:portfolio_id>/domains/",
|
||||
portfolio_domains,
|
||||
name="portfolio-domains",
|
||||
),
|
||||
path(
|
||||
"portfolio/<int:portfolio_id>/domain_requests/",
|
||||
portfolio_domain_requests,
|
||||
name="portfolio-domain-requests",
|
||||
),
|
||||
path(
|
||||
"admin/logout/",
|
||||
RedirectView.as_view(pattern_name="logout", permanent=False),
|
||||
|
|
|
@ -28,6 +28,7 @@ class Command(BaseCommand):
|
|||
* DomainInformation
|
||||
* DomainRequest
|
||||
* DraftDomain
|
||||
* FederalAgency
|
||||
* Host
|
||||
* HostIp
|
||||
* PublicContact
|
||||
|
@ -40,14 +41,15 @@ class Command(BaseCommand):
|
|||
table_names = [
|
||||
"DomainInformation",
|
||||
"DomainRequest",
|
||||
"FederalAgency",
|
||||
"PublicContact",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"Domain",
|
||||
"User",
|
||||
"Contact",
|
||||
"Website",
|
||||
"DraftDomain",
|
||||
"HostIp",
|
||||
"Host",
|
||||
]
|
||||
|
||||
for table_name in table_names:
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from django.core.paginator import Paginator
|
||||
import logging
|
||||
import os
|
||||
import pyzipper
|
||||
import tablib
|
||||
from django.core.management import BaseCommand
|
||||
import registrar.admin
|
||||
|
||||
|
@ -18,6 +20,7 @@ class Command(BaseCommand):
|
|||
"Domain",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"FederalAgency",
|
||||
"UserDomainRole",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
|
@ -36,28 +39,58 @@ class Command(BaseCommand):
|
|||
zip_filename = "tmp/exported_tables.zip"
|
||||
with pyzipper.AESZipFile(zip_filename, "w", compression=pyzipper.ZIP_DEFLATED) as zipf:
|
||||
for table_name in table_names:
|
||||
csv_filename = f"tmp/{table_name}.csv"
|
||||
if os.path.exists(csv_filename):
|
||||
zipf.write(csv_filename, os.path.basename(csv_filename))
|
||||
logger.info(f"Added {csv_filename} to zip archive {zip_filename}")
|
||||
|
||||
# Remove the CSV files after adding them to the zip file
|
||||
for table_name in table_names:
|
||||
csv_filename = f"tmp/{table_name}.csv"
|
||||
if os.path.exists(csv_filename):
|
||||
os.remove(csv_filename)
|
||||
logger.info(f"Removed temporary file {csv_filename}")
|
||||
# Define the tmp directory and the file pattern
|
||||
tmp_dir = "tmp"
|
||||
pattern = f"{table_name}_"
|
||||
zip_file_path = os.path.join(tmp_dir, "exported_files.zip")
|
||||
|
||||
# Find all files that match the pattern
|
||||
matching_files = [file for file in os.listdir(tmp_dir) if file.startswith(pattern)]
|
||||
for file_path in matching_files:
|
||||
# Add each file to the zip archive
|
||||
zipf.write(f"tmp/{file_path}", os.path.basename(file_path))
|
||||
logger.info(f"Added {file_path} to {zip_file_path}")
|
||||
|
||||
# Remove the file after adding to zip
|
||||
os.remove(f"tmp/{file_path}")
|
||||
logger.info(f"Removed {file_path}")
|
||||
|
||||
def export_table(self, table_name):
|
||||
"""Export a given table to a csv file in the tmp directory"""
|
||||
"""Export a given table to csv files in the tmp directory"""
|
||||
resourcename = f"{table_name}Resource"
|
||||
try:
|
||||
resourceclass = getattr(registrar.admin, resourcename)
|
||||
dataset = resourceclass().export()
|
||||
filename = f"tmp/{table_name}.csv"
|
||||
with open(filename, "w") as outputfile:
|
||||
outputfile.write(dataset.csv)
|
||||
logger.info(f"Successfully exported {table_name} to {filename}")
|
||||
if not isinstance(dataset, tablib.Dataset):
|
||||
raise ValueError(f"Exported data from {resourcename} is not a tablib.Dataset")
|
||||
|
||||
# Determine the number of rows per file
|
||||
rows_per_file = 10000
|
||||
|
||||
# Use Paginator to handle splitting the dataset
|
||||
paginator = Paginator(dataset.dict, rows_per_file)
|
||||
num_files = paginator.num_pages
|
||||
|
||||
logger.info(f"splitting {table_name} into {num_files} files")
|
||||
|
||||
# Export each page to a separate file
|
||||
for page_num in paginator.page_range:
|
||||
page = paginator.page(page_num)
|
||||
|
||||
# Create a new dataset for the chunk
|
||||
chunk = tablib.Dataset(headers=dataset.headers)
|
||||
for row_dict in page.object_list:
|
||||
row = [row_dict[header] for header in dataset.headers]
|
||||
chunk.append(row)
|
||||
|
||||
# Export the chunk to a new file
|
||||
filename = f"tmp/{table_name}_{page_num}.csv"
|
||||
with open(filename, "w") as f:
|
||||
f.write(chunk.export("csv"))
|
||||
|
||||
logger.info(f"Successfully exported {table_name} into {num_files} files.")
|
||||
|
||||
except AttributeError:
|
||||
logger.error(f"Resource class {resourcename} not found in registrar.admin")
|
||||
except Exception as e:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import pyzipper
|
||||
|
@ -14,6 +15,10 @@ logger = logging.getLogger(__name__)
|
|||
class Command(BaseCommand):
|
||||
help = "Imports tables from a zip file, exported_tables.zip, containing CSV files in the tmp directory."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add command line arguments."""
|
||||
parser.add_argument("--skipEppSave", default=True, action=argparse.BooleanOptionalAction)
|
||||
|
||||
def handle(self, **options):
|
||||
"""Extracts CSV files from a zip archive and imports them into the respective tables"""
|
||||
|
||||
|
@ -21,6 +26,8 @@ class Command(BaseCommand):
|
|||
logger.error("import_tables cannot be run in production")
|
||||
return
|
||||
|
||||
self.skip_epp_save = options.get("skipEppSave")
|
||||
|
||||
table_names = [
|
||||
"User",
|
||||
"Contact",
|
||||
|
@ -29,6 +36,7 @@ class Command(BaseCommand):
|
|||
"HostIp",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
"FederalAgency",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"UserDomainRole",
|
||||
|
@ -56,38 +64,46 @@ class Command(BaseCommand):
|
|||
"""Import data from a CSV file into the given table"""
|
||||
|
||||
resourcename = f"{table_name}Resource"
|
||||
csv_filename = f"tmp/{table_name}.csv"
|
||||
try:
|
||||
if not os.path.exists(csv_filename):
|
||||
logger.error(f"CSV file {csv_filename} not found.")
|
||||
return
|
||||
|
||||
# if table_name is Contact, clean the table first
|
||||
# User table is loaded before Contact, and signals create
|
||||
# rows in Contact table which break the import, so need
|
||||
# to be cleaned again before running import on Contact table
|
||||
if table_name == "Contact":
|
||||
self.clean_table(table_name)
|
||||
# if table_name is Contact, clean the table first
|
||||
# User table is loaded before Contact, and signals create
|
||||
# rows in Contact table which break the import, so need
|
||||
# to be cleaned again before running import on Contact table
|
||||
if table_name == "Contact":
|
||||
self.clean_table(table_name)
|
||||
|
||||
resourceclass = getattr(registrar.admin, resourcename)
|
||||
resource_instance = resourceclass()
|
||||
with open(csv_filename, "r") as csvfile:
|
||||
dataset = tablib.Dataset().load(csvfile.read(), format="csv")
|
||||
result = resource_instance.import_data(dataset, dry_run=False, skip_epp_save=True)
|
||||
# Define the directory and the pattern for csv filenames
|
||||
tmp_dir = "tmp"
|
||||
pattern = f"{table_name}_"
|
||||
|
||||
if result.has_errors():
|
||||
logger.error(f"Errors occurred while importing {csv_filename}: {result.row_errors()}")
|
||||
else:
|
||||
logger.info(f"Successfully imported {csv_filename} into {table_name}")
|
||||
resourceclass = getattr(registrar.admin, resourcename)
|
||||
resource_instance = resourceclass()
|
||||
|
||||
except AttributeError:
|
||||
logger.error(f"Resource class {resourcename} not found in registrar.admin")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import {csv_filename}: {e}")
|
||||
finally:
|
||||
if os.path.exists(csv_filename):
|
||||
os.remove(csv_filename)
|
||||
logger.info(f"Removed temporary file {csv_filename}")
|
||||
# Find all files that match the pattern
|
||||
matching_files = [file for file in os.listdir(tmp_dir) if file.startswith(pattern)]
|
||||
for csv_filename in matching_files:
|
||||
try:
|
||||
with open(f"tmp/{csv_filename}", "r") as csvfile:
|
||||
dataset = tablib.Dataset().load(csvfile.read(), format="csv")
|
||||
result = resource_instance.import_data(dataset, dry_run=False, skip_epp_save=self.skip_epp_save)
|
||||
if result.has_errors():
|
||||
logger.error(f"Errors occurred while importing {csv_filename}:")
|
||||
for row_error in result.row_errors():
|
||||
row_index = row_error[0]
|
||||
errors = row_error[1]
|
||||
for error in errors:
|
||||
logger.error(f"Row {row_index} - {error.error} - {error.row}")
|
||||
else:
|
||||
logger.info(f"Successfully imported {csv_filename} into {table_name}")
|
||||
|
||||
except AttributeError:
|
||||
logger.error(f"Resource class {resourcename} not found in registrar.admin")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import {csv_filename}: {e}")
|
||||
finally:
|
||||
if os.path.exists(csv_filename):
|
||||
os.remove(csv_filename)
|
||||
logger.info(f"Removed temporary file {csv_filename}")
|
||||
|
||||
def clean_table(self, table_name):
|
||||
"""Delete all rows in the given table"""
|
||||
|
|
|
@ -0,0 +1,174 @@
|
|||
# Generated by Django 4.2.10 on 2024-06-18 17:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import registrar.models.federal_agency
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0102_domain_dsdata_last_change"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Portfolio",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("notes", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"organization_type",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("federal", "Federal"),
|
||||
("interstate", "Interstate"),
|
||||
("state_or_territory", "State or territory"),
|
||||
("tribal", "Tribal"),
|
||||
("county", "County"),
|
||||
("city", "City"),
|
||||
("special_district", "Special district"),
|
||||
("school_district", "School district"),
|
||||
],
|
||||
help_text="Type of organization",
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("organization_name", models.CharField(blank=True, null=True)),
|
||||
("address_line1", models.CharField(blank=True, null=True, verbose_name="address line 1")),
|
||||
("address_line2", models.CharField(blank=True, null=True, verbose_name="address line 2")),
|
||||
("city", models.CharField(blank=True, null=True)),
|
||||
(
|
||||
"state_territory",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("AL", "Alabama (AL)"),
|
||||
("AK", "Alaska (AK)"),
|
||||
("AS", "American Samoa (AS)"),
|
||||
("AZ", "Arizona (AZ)"),
|
||||
("AR", "Arkansas (AR)"),
|
||||
("CA", "California (CA)"),
|
||||
("CO", "Colorado (CO)"),
|
||||
("CT", "Connecticut (CT)"),
|
||||
("DE", "Delaware (DE)"),
|
||||
("DC", "District of Columbia (DC)"),
|
||||
("FL", "Florida (FL)"),
|
||||
("GA", "Georgia (GA)"),
|
||||
("GU", "Guam (GU)"),
|
||||
("HI", "Hawaii (HI)"),
|
||||
("ID", "Idaho (ID)"),
|
||||
("IL", "Illinois (IL)"),
|
||||
("IN", "Indiana (IN)"),
|
||||
("IA", "Iowa (IA)"),
|
||||
("KS", "Kansas (KS)"),
|
||||
("KY", "Kentucky (KY)"),
|
||||
("LA", "Louisiana (LA)"),
|
||||
("ME", "Maine (ME)"),
|
||||
("MD", "Maryland (MD)"),
|
||||
("MA", "Massachusetts (MA)"),
|
||||
("MI", "Michigan (MI)"),
|
||||
("MN", "Minnesota (MN)"),
|
||||
("MS", "Mississippi (MS)"),
|
||||
("MO", "Missouri (MO)"),
|
||||
("MT", "Montana (MT)"),
|
||||
("NE", "Nebraska (NE)"),
|
||||
("NV", "Nevada (NV)"),
|
||||
("NH", "New Hampshire (NH)"),
|
||||
("NJ", "New Jersey (NJ)"),
|
||||
("NM", "New Mexico (NM)"),
|
||||
("NY", "New York (NY)"),
|
||||
("NC", "North Carolina (NC)"),
|
||||
("ND", "North Dakota (ND)"),
|
||||
("MP", "Northern Mariana Islands (MP)"),
|
||||
("OH", "Ohio (OH)"),
|
||||
("OK", "Oklahoma (OK)"),
|
||||
("OR", "Oregon (OR)"),
|
||||
("PA", "Pennsylvania (PA)"),
|
||||
("PR", "Puerto Rico (PR)"),
|
||||
("RI", "Rhode Island (RI)"),
|
||||
("SC", "South Carolina (SC)"),
|
||||
("SD", "South Dakota (SD)"),
|
||||
("TN", "Tennessee (TN)"),
|
||||
("TX", "Texas (TX)"),
|
||||
("UM", "United States Minor Outlying Islands (UM)"),
|
||||
("UT", "Utah (UT)"),
|
||||
("VT", "Vermont (VT)"),
|
||||
("VI", "Virgin Islands (VI)"),
|
||||
("VA", "Virginia (VA)"),
|
||||
("WA", "Washington (WA)"),
|
||||
("WV", "West Virginia (WV)"),
|
||||
("WI", "Wisconsin (WI)"),
|
||||
("WY", "Wyoming (WY)"),
|
||||
("AA", "Armed Forces Americas (AA)"),
|
||||
("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"),
|
||||
("AP", "Armed Forces Pacific (AP)"),
|
||||
],
|
||||
max_length=2,
|
||||
null=True,
|
||||
verbose_name="state / territory",
|
||||
),
|
||||
),
|
||||
("zipcode", models.CharField(blank=True, max_length=10, null=True, verbose_name="zip code")),
|
||||
(
|
||||
"urbanization",
|
||||
models.CharField(
|
||||
blank=True, help_text="Required for Puerto Rico only", null=True, verbose_name="urbanization"
|
||||
),
|
||||
),
|
||||
(
|
||||
"security_contact_email",
|
||||
models.EmailField(blank=True, max_length=320, null=True, verbose_name="security contact e-mail"),
|
||||
),
|
||||
(
|
||||
"creator",
|
||||
models.ForeignKey(
|
||||
help_text="Associated user",
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"federal_agency",
|
||||
models.ForeignKey(
|
||||
default=registrar.models.federal_agency.FederalAgency.get_non_federal_agency,
|
||||
help_text="Associated federal agency",
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="registrar.federalagency",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="domaininformation",
|
||||
name="portfolio",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Portfolio associated with this domain",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="DomainRequest_portfolio",
|
||||
to="registrar.portfolio",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="domainrequest",
|
||||
name="portfolio",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Portfolio associated with this domain request",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="DomainInformation_portfolio",
|
||||
to="registrar.portfolio",
|
||||
),
|
||||
),
|
||||
]
|
37
src/registrar/migrations/0104_create_groups_v13.py
Normal file
37
src/registrar/migrations/0104_create_groups_v13.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
|
||||
# It is dependent on 0079 (which populates federal agencies)
|
||||
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
|
||||
# in the user_group model then:
|
||||
# [NOT RECOMMENDED]
|
||||
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
|
||||
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
|
||||
# step 3: fake run the latest migration in the migrations list
|
||||
# [RECOMMENDED]
|
||||
# Alternatively:
|
||||
# step 1: duplicate the migration that loads data
|
||||
# step 2: docker-compose exec app ./manage.py migrate
|
||||
|
||||
from django.db import migrations
|
||||
from registrar.models import UserGroup
|
||||
from typing import Any
|
||||
|
||||
|
||||
# For linting: RunPython expects a function reference,
|
||||
# so let's give it one
|
||||
def create_groups(apps, schema_editor) -> Any:
|
||||
UserGroup.create_cisa_analyst_group(apps, schema_editor)
|
||||
UserGroup.create_full_access_group(apps, schema_editor)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("registrar", "0103_portfolio_domaininformation_portfolio_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
create_groups,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
atomic=True,
|
||||
),
|
||||
]
|
41
src/registrar/migrations/0105_suborganization_domaingroup.py
Normal file
41
src/registrar/migrations/0105_suborganization_domaingroup.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 4.2.10 on 2024-06-21 18:15
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0104_create_groups_v13"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Suborganization",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(help_text="Suborganization", max_length=1000, unique=True)),
|
||||
("portfolio", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="registrar.portfolio")),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="DomainGroup",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(help_text="Domain group", unique=True)),
|
||||
("domains", models.ManyToManyField(blank=True, to="registrar.domaininformation")),
|
||||
("portfolio", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="registrar.portfolio")),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("name", "portfolio")},
|
||||
},
|
||||
),
|
||||
]
|
37
src/registrar/migrations/0106_create_groups_v14.py
Normal file
37
src/registrar/migrations/0106_create_groups_v14.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
|
||||
# It is dependent on 0079 (which populates federal agencies)
|
||||
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
|
||||
# in the user_group model then:
|
||||
# [NOT RECOMMENDED]
|
||||
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
|
||||
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
|
||||
# step 3: fake run the latest migration in the migrations list
|
||||
# [RECOMMENDED]
|
||||
# Alternatively:
|
||||
# step 1: duplicate the migration that loads data
|
||||
# step 2: docker-compose exec app ./manage.py migrate
|
||||
|
||||
from django.db import migrations
|
||||
from registrar.models import UserGroup
|
||||
from typing import Any
|
||||
|
||||
|
||||
# For linting: RunPython expects a function reference,
|
||||
# so let's give it one
|
||||
def create_groups(apps, schema_editor) -> Any:
|
||||
UserGroup.create_cisa_analyst_group(apps, schema_editor)
|
||||
UserGroup.create_full_access_group(apps, schema_editor)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("registrar", "0105_suborganization_domaingroup"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
create_groups,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
atomic=True,
|
||||
),
|
||||
]
|
|
@ -16,6 +16,9 @@ from .website import Website
|
|||
from .transition_domain import TransitionDomain
|
||||
from .verified_by_staff import VerifiedByStaff
|
||||
from .waffle_flag import WaffleFlag
|
||||
from .portfolio import Portfolio
|
||||
from .domain_group import DomainGroup
|
||||
from .suborganization import Suborganization
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
@ -36,6 +39,9 @@ __all__ = [
|
|||
"TransitionDomain",
|
||||
"VerifiedByStaff",
|
||||
"WaffleFlag",
|
||||
"Portfolio",
|
||||
"DomainGroup",
|
||||
"Suborganization",
|
||||
]
|
||||
|
||||
auditlog.register(Contact)
|
||||
|
@ -55,3 +61,6 @@ auditlog.register(Website)
|
|||
auditlog.register(TransitionDomain)
|
||||
auditlog.register(VerifiedByStaff)
|
||||
auditlog.register(WaffleFlag)
|
||||
auditlog.register(Portfolio)
|
||||
auditlog.register(DomainGroup)
|
||||
auditlog.register(Suborganization)
|
||||
|
|
23
src/registrar/models/domain_group.py
Normal file
23
src/registrar/models/domain_group.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from django.db import models
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
||||
|
||||
class DomainGroup(TimeStampedModel):
|
||||
"""
|
||||
Organized group of domains.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
unique_together = [("name", "portfolio")]
|
||||
|
||||
name = models.CharField(
|
||||
unique=True,
|
||||
help_text="Domain group",
|
||||
)
|
||||
|
||||
portfolio = models.ForeignKey("registrar.Portfolio", on_delete=models.PROTECT)
|
||||
|
||||
domains = models.ManyToManyField("registrar.DomainInformation", blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name}"
|
|
@ -57,6 +57,16 @@ class DomainInformation(TimeStampedModel):
|
|||
help_text="Person who submitted the domain request",
|
||||
)
|
||||
|
||||
# portfolio
|
||||
portfolio = models.ForeignKey(
|
||||
"registrar.Portfolio",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="DomainRequest_portfolio",
|
||||
help_text="Portfolio associated with this domain",
|
||||
)
|
||||
|
||||
domain_request = models.OneToOneField(
|
||||
"registrar.DomainRequest",
|
||||
on_delete=models.PROTECT,
|
||||
|
|
|
@ -303,6 +303,16 @@ class DomainRequest(TimeStampedModel):
|
|||
null=True,
|
||||
)
|
||||
|
||||
# portfolio
|
||||
portfolio = models.ForeignKey(
|
||||
"registrar.Portfolio",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="DomainInformation_portfolio",
|
||||
help_text="Portfolio associated with this domain request",
|
||||
)
|
||||
|
||||
# This is the domain request user who created this domain request. The contact
|
||||
# information that they gave is in the `submitter` field
|
||||
creator = models.ForeignKey(
|
||||
|
|
|
@ -230,3 +230,8 @@ class FederalAgency(TimeStampedModel):
|
|||
FederalAgency.objects.bulk_create(agencies)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating federal agencies: {e}")
|
||||
|
||||
@classmethod
|
||||
def get_non_federal_agency(cls):
|
||||
"""Returns the non-federal agency."""
|
||||
return FederalAgency.objects.filter(agency="Non-Federal Agency").first()
|
||||
|
|
102
src/registrar/models/portfolio.py
Normal file
102
src/registrar/models/portfolio.py
Normal file
|
@ -0,0 +1,102 @@
|
|||
from django.db import models
|
||||
|
||||
from registrar.models.domain_request import DomainRequest
|
||||
from registrar.models.federal_agency import FederalAgency
|
||||
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
||||
|
||||
# def get_default_federal_agency():
|
||||
# """returns non-federal agency"""
|
||||
# return FederalAgency.objects.filter(agency="Non-Federal Agency").first()
|
||||
|
||||
|
||||
class Portfolio(TimeStampedModel):
|
||||
"""
|
||||
Portfolio is used for organizing domains/domain-requests into
|
||||
manageable groups.
|
||||
"""
|
||||
|
||||
# use the short names in Django admin
|
||||
OrganizationChoices = DomainRequest.OrganizationChoices
|
||||
StateTerritoryChoices = DomainRequest.StateTerritoryChoices
|
||||
|
||||
# Stores who created this model. If no creator is specified in DJA,
|
||||
# then the creator will default to the current request user"""
|
||||
creator = models.ForeignKey("registrar.User", on_delete=models.PROTECT, help_text="Associated user", unique=False)
|
||||
|
||||
notes = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
federal_agency = models.ForeignKey(
|
||||
"registrar.FederalAgency",
|
||||
on_delete=models.PROTECT,
|
||||
help_text="Associated federal agency",
|
||||
unique=False,
|
||||
default=FederalAgency.get_non_federal_agency,
|
||||
)
|
||||
|
||||
organization_type = models.CharField(
|
||||
max_length=255,
|
||||
choices=OrganizationChoices.choices,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Type of organization",
|
||||
)
|
||||
|
||||
organization_name = models.CharField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
address_line1 = models.CharField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="address line 1",
|
||||
)
|
||||
|
||||
address_line2 = models.CharField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="address line 2",
|
||||
)
|
||||
|
||||
city = models.CharField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
# (imports enums from domain_request.py)
|
||||
state_territory = models.CharField(
|
||||
max_length=2,
|
||||
choices=StateTerritoryChoices.choices,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="state / territory",
|
||||
)
|
||||
|
||||
zipcode = models.CharField(
|
||||
max_length=10,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="zip code",
|
||||
)
|
||||
|
||||
urbanization = models.CharField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Required for Puerto Rico only",
|
||||
verbose_name="urbanization",
|
||||
)
|
||||
|
||||
security_contact_email = models.EmailField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="security contact e-mail",
|
||||
max_length=320,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.organization_name}"
|
22
src/registrar/models/suborganization.py
Normal file
22
src/registrar/models/suborganization.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
from django.db import models
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
||||
|
||||
class Suborganization(TimeStampedModel):
|
||||
"""
|
||||
Suborganization under an organization (portfolio)
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
unique=True,
|
||||
max_length=1000,
|
||||
help_text="Suborganization",
|
||||
)
|
||||
|
||||
portfolio = models.ForeignKey(
|
||||
"registrar.Portfolio",
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name}"
|
|
@ -2,14 +2,18 @@
|
|||
Contains middleware used in settings.py
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import parse_qs
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.user import User
|
||||
from waffle.decorators import flag_is_active
|
||||
|
||||
from registrar.models.utility.generic_helper import replace_url_queryparams
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NoCacheMiddleware:
|
||||
"""
|
||||
|
@ -117,4 +121,34 @@ class CheckUserProfileMiddleware:
|
|||
return HttpResponseRedirect(new_setup_page)
|
||||
else:
|
||||
# Process the view as normal
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class CheckPortfolioMiddleware:
|
||||
"""
|
||||
Checks if the current user has a portfolio
|
||||
If they do, redirect them to the portfolio homepage when they navigate to home.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.home = reverse("home")
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||
current_path = request.path
|
||||
|
||||
has_organization_feature_flag = flag_is_active(request, "organization_feature")
|
||||
|
||||
if current_path == self.home:
|
||||
if has_organization_feature_flag:
|
||||
if request.user.is_authenticated:
|
||||
user_portfolios = Portfolio.objects.filter(creator=request.user)
|
||||
if user_portfolios.exists():
|
||||
first_portfolio = user_portfolios.first()
|
||||
home_with_portfolio = reverse("portfolio-domains", kwargs={"portfolio_id": first_portfolio.id})
|
||||
return HttpResponseRedirect(home_with_portfolio)
|
||||
return None
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
</legend>
|
||||
|
||||
<!-- Toggle -->
|
||||
<em>Select one (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
|
||||
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
|
||||
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.0.has_cisa_representative %}
|
||||
{% endwith %}
|
||||
|
@ -36,7 +36,7 @@
|
|||
</legend>
|
||||
|
||||
<!-- Toggle -->
|
||||
<em>Select one (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
|
||||
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
|
||||
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.2.has_anything_else_text %}
|
||||
{% endwith %}
|
||||
|
@ -44,7 +44,7 @@
|
|||
</fieldset>
|
||||
|
||||
<div class="margin-top-3" id="anything-else">
|
||||
<p>Provide details below (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</p>
|
||||
<p>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p>
|
||||
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.3.anything_else %}
|
||||
{% endwith %}
|
||||
|
|
|
@ -9,189 +9,48 @@
|
|||
{% if user.is_authenticated %}
|
||||
{# the entire logged in page goes here #}
|
||||
|
||||
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
|
||||
{% block messages %}
|
||||
{% include "includes/form_messages.html" %}
|
||||
{% endblock %}
|
||||
<h1>Manage your domains</h2>
|
||||
{% block homepage_content %}
|
||||
|
||||
{% comment %}
|
||||
IMPORTANT:
|
||||
If this button is added on any other page, make sure to update the
|
||||
relevant view to reset request.session["new_request"] = True
|
||||
{% endcomment %}
|
||||
<p class="margin-top-4">
|
||||
<a href="{% url 'domain-request:' %}" class="usa-button"
|
||||
>
|
||||
Start a new domain request
|
||||
</a>
|
||||
</p>
|
||||
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
|
||||
{% block messages %}
|
||||
{% include "includes/form_messages.html" %}
|
||||
{% endblock %}
|
||||
<h1>Manage your domains</h1>
|
||||
|
||||
<section class="section--outlined domains">
|
||||
<div class="grid-row">
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domains-header" class="flex-6">Domains</h2>
|
||||
</div>
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<section aria-label="Domains search component" class="flex-6 margin-y-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-button display-none" type="button">
|
||||
Reset
|
||||
</button>
|
||||
<label class="usa-sr-only" for="domains__search-field">Search</label>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="domains__search-field"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search by domain name"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="domains__search-field-submit">
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="domains__table-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table">
|
||||
<caption class="sr-only">Your registered domains</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||
<th
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="domains__no-data display-none">
|
||||
<p>You don't have any registered domains.</p>
|
||||
<p class="maxw-none clearfix">
|
||||
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
|
||||
</svg>
|
||||
Why don't I see my domain when I sign in to the registrar?
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="domains__no-search-results display-none">
|
||||
<p>No results found for "<span class="domains__search-term"></span>"</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
||||
{% comment %}
|
||||
IMPORTANT:
|
||||
If this button is added on any other page, make sure to update the
|
||||
relevant view to reset request.session["new_request"] = True
|
||||
{% endcomment %}
|
||||
<p class="margin-top-4">
|
||||
<a href="{% url 'domain-request:' %}" class="usa-button"
|
||||
>
|
||||
Start a new domain request
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<section class="section--outlined domain-requests">
|
||||
<div class="grid-row">
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
||||
</div>
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-button display-none" type="button">
|
||||
Reset
|
||||
</button>
|
||||
<label class="usa-sr-only" for="domain-requests__search-field">Search</label>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="domain-requests__search-field"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search by domain name"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="domain-requests__table-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
|
||||
<caption class="sr-only">Your domain requests</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th>
|
||||
<th data-sortable="status" scope="col" role="columnheader">Status</th>
|
||||
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
|
||||
<!-- AJAX will conditionally add a th for delete actions -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domain-requests-tbody">
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="domain-requests__no-data display-none">
|
||||
<p>You haven't requested any domains.</p>
|
||||
</div>
|
||||
<div class="domain-requests__no-search-results display-none">
|
||||
<p>No results found for "<span class="domain-requests__search-term"></span>"</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
||||
{% include "includes/domains_table.html" %}
|
||||
{% include "includes/domain_requests_table.html" %}
|
||||
|
||||
{# Note: Reimplement this after MVP #}
|
||||
<!--
|
||||
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
|
||||
<h2>Archived domains</h2>
|
||||
<p>You don't have any archived domains</p>
|
||||
</section>
|
||||
-->
|
||||
{# Note: Reimplement this after MVP #}
|
||||
<!--
|
||||
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
|
||||
<h2>Archived domains</h2>
|
||||
<p>You don't have any archived domains</p>
|
||||
</section>
|
||||
-->
|
||||
|
||||
<!-- Note: Uncomment below when this is being implemented post-MVP -->
|
||||
<!-- <section class="tablet:grid-col-11 desktop:grid-col-10">
|
||||
<h2 class="padding-top-1 mobile-lg:padding-top-3"> Export domains</h2>
|
||||
<p>Download a list of your domains and their statuses as a csv file.</p>
|
||||
<a href="{% url 'todo' %}" class="usa-button usa-button--outline">
|
||||
Export domains as csv
|
||||
</a>
|
||||
</section>
|
||||
-->
|
||||
<!-- Note: Uncomment below when this is being implemented post-MVP -->
|
||||
<!-- <section class="tablet:grid-col-11 desktop:grid-col-10">
|
||||
<h2 class="padding-top-1 mobile-lg:padding-top-3"> Export domains</h2>
|
||||
<p>Download a list of your domains and their statuses as a csv file.</p>
|
||||
<a href="{% url 'todo' %}" class="usa-button usa-button--outline">
|
||||
Export domains as csv
|
||||
</a>
|
||||
</section>
|
||||
-->
|
||||
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% else %} {# not user.is_authenticated #}
|
||||
|
|
71
src/registrar/templates/includes/domain_requests_table.html
Normal file
71
src/registrar/templates/includes/domain_requests_table.html
Normal file
|
@ -0,0 +1,71 @@
|
|||
{% load static %}
|
||||
|
||||
<section class="section--outlined domain-requests">
|
||||
<div class="grid-row">
|
||||
{% if portfolio is None %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-button display-none" type="button">
|
||||
Reset
|
||||
</button>
|
||||
<label class="usa-sr-only" for="domain-requests__search-field">Search</label>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="domain-requests__search-field"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search by domain name"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="domain-requests__table-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
|
||||
<caption class="sr-only">Your domain requests</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th>
|
||||
<th data-sortable="status" scope="col" role="columnheader">Status</th>
|
||||
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
|
||||
<!-- AJAX will conditionally add a th for delete actions -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domain-requests-tbody">
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="domain-requests__no-data display-none">
|
||||
<p>You haven't requested any domains.</p>
|
||||
</div>
|
||||
<div class="domain-requests__no-search-results display-none">
|
||||
<p>No results found for "<span class="domain-requests__search-term"></span>"</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
83
src/registrar/templates/includes/domains_table.html
Normal file
83
src/registrar/templates/includes/domains_table.html
Normal file
|
@ -0,0 +1,83 @@
|
|||
{% load static %}
|
||||
|
||||
<section class="section--outlined domains">
|
||||
<div class="grid-row">
|
||||
{% if portfolio is None %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domains-header" class="flex-6">Domains</h2>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<section aria-label="Domains search component" class="flex-6 margin-y-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-button display-none" type="button">
|
||||
Reset
|
||||
</button>
|
||||
<label class="usa-sr-only" for="domains__search-field">Search</label>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="domains__search-field"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search by domain name"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="domains__search-field-submit">
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="domains__table-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table">
|
||||
<caption class="sr-only">Your registered domains</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||
<th
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="domains__no-data display-none">
|
||||
<p>You don't have any registered domains.</p>
|
||||
<p class="maxw-none clearfix">
|
||||
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
|
||||
</svg>
|
||||
Why don't I see my domain when I sign in to the registrar?
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="domains__no-search-results display-none">
|
||||
<p>No results found for "<span class="domains__search-term"></span>"</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
24
src/registrar/templates/portfolio.html
Normal file
24
src/registrar/templates/portfolio.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends 'home.html' %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block homepage_content %}
|
||||
|
||||
<div class="tablet:grid-col-12">
|
||||
<div class="grid-row grid-gap">
|
||||
<div class="tablet:grid-col-3">
|
||||
{% include "portfolio_sidebar.html" with portfolio=portfolio %}
|
||||
</div>
|
||||
<div class="tablet:grid-col-9">
|
||||
{% block messages %}
|
||||
{% include "includes/form_messages.html" %}
|
||||
{% endblock %}
|
||||
{# Note: Reimplement commented out functionality #}
|
||||
|
||||
{% block portfolio_content %}
|
||||
{% endblock %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
8
src/registrar/templates/portfolio_domains.html
Normal file
8
src/registrar/templates/portfolio_domains.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends 'portfolio.html' %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<h1>Domains</h1>
|
||||
{% include "includes/domains_table.html" with portfolio=portfolio %}
|
||||
{% endblock %}
|
21
src/registrar/templates/portfolio_requests.html
Normal file
21
src/registrar/templates/portfolio_requests.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends 'portfolio.html' %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<h1>Domain requests</h1>
|
||||
|
||||
{% comment %}
|
||||
IMPORTANT:
|
||||
If this button is added on any other page, make sure to update the
|
||||
relevant view to reset request.session["new_request"] = True
|
||||
{% endcomment %}
|
||||
<p class="margin-top-4">
|
||||
<a href="{% url 'domain-request:' %}" class="usa-button"
|
||||
>
|
||||
Start a new domain request
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{% include "includes/domain_requests_table.html" with portfolio=portfolio %}
|
||||
{% endblock %}
|
37
src/registrar/templates/portfolio_sidebar.html
Normal file
37
src/registrar/templates/portfolio_sidebar.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
{% load static url_helpers %}
|
||||
|
||||
<div class="margin-bottom-4 tablet:margin-bottom-0">
|
||||
<nav aria-label="">
|
||||
<h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2>
|
||||
<ul class="usa-sidenav">
|
||||
<li class="usa-sidenav__item">
|
||||
{% url 'portfolio-domains' portfolio.id as url %}
|
||||
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
|
||||
Domains
|
||||
</a>
|
||||
|
||||
</li>
|
||||
<li class="usa-sidenav__item">
|
||||
{% url 'portfolio-domain-requests' portfolio.id as url %}
|
||||
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
|
||||
Domain requests
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-sidenav__item">
|
||||
<a href="#">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-sidenav__item">
|
||||
<a href="#">
|
||||
Organization
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-sidenav__item">
|
||||
<a href="#">
|
||||
Senior official
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
|
@ -2291,6 +2291,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"rejection_reason",
|
||||
"action_needed_reason",
|
||||
"federal_agency",
|
||||
"portfolio",
|
||||
"creator",
|
||||
"investigator",
|
||||
"generic_org_type",
|
||||
|
|
|
@ -7,6 +7,7 @@ from django.utils.module_loading import import_string
|
|||
import logging
|
||||
import pyzipper
|
||||
from registrar.management.commands.clean_tables import Command as CleanTablesCommand
|
||||
from registrar.management.commands.export_tables import Command as ExportTablesCommand
|
||||
from registrar.models import (
|
||||
User,
|
||||
Domain,
|
||||
|
@ -873,84 +874,81 @@ class TestExportTables(MockEppLib):
|
|||
"""Test the export_tables script"""
|
||||
|
||||
def setUp(self):
|
||||
self.command = ExportTablesCommand()
|
||||
self.logger_patcher = patch("registrar.management.commands.export_tables.logger")
|
||||
self.logger_mock = self.logger_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.logger_patcher.stop()
|
||||
|
||||
@patch("registrar.management.commands.export_tables.os.makedirs")
|
||||
@patch("registrar.management.commands.export_tables.os.path.exists")
|
||||
@patch("registrar.management.commands.export_tables.os.remove")
|
||||
@patch("registrar.management.commands.export_tables.pyzipper.AESZipFile")
|
||||
@patch("os.makedirs")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.remove")
|
||||
@patch("pyzipper.AESZipFile")
|
||||
@patch("registrar.management.commands.export_tables.getattr")
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=b"mock_csv_data")
|
||||
@patch("django.utils.translation.trans_real._translations", {})
|
||||
@patch("django.utils.translation.trans_real.translation")
|
||||
@patch("builtins.open", new_callable=mock_open)
|
||||
@patch("os.listdir")
|
||||
def test_handle(
|
||||
self, mock_translation, mock_file, mock_getattr, mock_zipfile, mock_remove, mock_path_exists, mock_makedirs
|
||||
self, mock_listdir, mock_open, mock_getattr, mock_zipfile, mock_remove, mock_path_exists, mock_makedirs
|
||||
):
|
||||
"""test that the handle method properly exports tables"""
|
||||
with less_console_noise():
|
||||
# Mock os.makedirs to do nothing
|
||||
mock_makedirs.return_value = None
|
||||
# Mock os.makedirs to do nothing
|
||||
mock_makedirs.return_value = None
|
||||
|
||||
# Mock os.path.exists to always return True
|
||||
mock_path_exists.return_value = True
|
||||
# Mock os.path.exists to always return True
|
||||
mock_path_exists.return_value = True
|
||||
|
||||
# Mock the resource class and its export method
|
||||
mock_resource_class = MagicMock()
|
||||
mock_dataset = MagicMock()
|
||||
mock_dataset.csv = b"mock_csv_data"
|
||||
mock_resource_class().export.return_value = mock_dataset
|
||||
mock_getattr.return_value = mock_resource_class
|
||||
# Check that the export_table function was called for each table
|
||||
table_names = [
|
||||
"User",
|
||||
"Contact",
|
||||
"Domain",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"FederalAgency",
|
||||
"UserDomainRole",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"PublicContact",
|
||||
]
|
||||
|
||||
# Mock translation function to return a dummy translation object
|
||||
mock_translation.return_value = MagicMock()
|
||||
# Mock directory listing
|
||||
mock_listdir.side_effect = lambda path: [f"{table}_1.csv" for table in table_names]
|
||||
|
||||
call_command("export_tables")
|
||||
# Mock the resource class and its export method
|
||||
mock_dataset = tablib.Dataset()
|
||||
mock_dataset.headers = ["header1", "header2"]
|
||||
mock_dataset.append(["row1_col1", "row1_col2"])
|
||||
mock_resource_class = MagicMock()
|
||||
mock_resource_class().export.return_value = mock_dataset
|
||||
mock_getattr.return_value = mock_resource_class
|
||||
|
||||
# Check that os.makedirs was called once to create the tmp directory
|
||||
mock_makedirs.assert_called_once_with("tmp", exist_ok=True)
|
||||
command_instance = ExportTablesCommand()
|
||||
command_instance.handle()
|
||||
|
||||
# Check that the export_table function was called for each table
|
||||
table_names = [
|
||||
"User",
|
||||
"Contact",
|
||||
"Domain",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"UserDomainRole",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"PublicContact",
|
||||
]
|
||||
# Check that os.makedirs was called once to create the tmp directory
|
||||
mock_makedirs.assert_called_once_with("tmp", exist_ok=True)
|
||||
|
||||
# Check that the CSV file was written
|
||||
for table_name in table_names:
|
||||
mock_file().write.assert_any_call(b"mock_csv_data")
|
||||
# Check that os.path.exists was called
|
||||
mock_path_exists.assert_any_call(f"tmp/{table_name}.csv")
|
||||
# Check that os.remove was called
|
||||
mock_remove.assert_any_call(f"tmp/{table_name}.csv")
|
||||
# Check that the CSV file was written
|
||||
for table_name in table_names:
|
||||
# Check that os.remove was called
|
||||
mock_remove.assert_any_call(f"tmp/{table_name}_1.csv")
|
||||
|
||||
# Check that the zipfile was created and files were added
|
||||
mock_zipfile.assert_called_once_with("tmp/exported_tables.zip", "w", compression=pyzipper.ZIP_DEFLATED)
|
||||
zipfile_instance = mock_zipfile.return_value.__enter__.return_value
|
||||
for table_name in table_names:
|
||||
zipfile_instance.write.assert_any_call(f"tmp/{table_name}.csv", f"{table_name}.csv")
|
||||
# Check that the zipfile was created and files were added
|
||||
mock_zipfile.assert_called_once_with("tmp/exported_tables.zip", "w", compression=pyzipper.ZIP_DEFLATED)
|
||||
zipfile_instance = mock_zipfile.return_value.__enter__.return_value
|
||||
for table_name in table_names:
|
||||
zipfile_instance.write.assert_any_call(f"tmp/{table_name}_1.csv", f"{table_name}_1.csv")
|
||||
|
||||
# Verify logging for added files
|
||||
for table_name in table_names:
|
||||
self.logger_mock.info.assert_any_call(
|
||||
f"Added tmp/{table_name}.csv to zip archive tmp/exported_tables.zip"
|
||||
)
|
||||
# Verify logging for added files
|
||||
for table_name in table_names:
|
||||
self.logger_mock.info.assert_any_call(f"Added {table_name}_1.csv to tmp/exported_files.zip")
|
||||
|
||||
# Verify logging for removed files
|
||||
for table_name in table_names:
|
||||
self.logger_mock.info.assert_any_call(f"Removed temporary file tmp/{table_name}.csv")
|
||||
# Verify logging for removed files
|
||||
for table_name in table_names:
|
||||
self.logger_mock.info.assert_any_call(f"Removed {table_name}_1.csv")
|
||||
|
||||
@patch("registrar.management.commands.export_tables.getattr")
|
||||
def test_export_table_handles_missing_resource_class(self, mock_getattr):
|
||||
|
@ -995,8 +993,10 @@ class TestImportTables(TestCase):
|
|||
@patch("registrar.management.commands.import_tables.logger")
|
||||
@patch("registrar.management.commands.import_tables.getattr")
|
||||
@patch("django.apps.apps.get_model")
|
||||
@patch("os.listdir")
|
||||
def test_handle(
|
||||
self,
|
||||
mock_listdir,
|
||||
mock_get_model,
|
||||
mock_getattr,
|
||||
mock_logger,
|
||||
|
@ -1019,6 +1019,24 @@ class TestImportTables(TestCase):
|
|||
mock_zipfile_instance = mock_zipfile.return_value.__enter__.return_value
|
||||
mock_zipfile_instance.extractall.return_value = None
|
||||
|
||||
# Check that the import_table function was called for each table
|
||||
table_names = [
|
||||
"User",
|
||||
"Contact",
|
||||
"Domain",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"UserDomainRole",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"PublicContact",
|
||||
]
|
||||
|
||||
# Mock directory listing
|
||||
mock_listdir.side_effect = lambda path: [f"{table}_1.csv" for table in table_names]
|
||||
|
||||
# Mock the CSV file content
|
||||
csv_content = b"mock_csv_data"
|
||||
|
||||
|
@ -1054,23 +1072,9 @@ class TestImportTables(TestCase):
|
|||
# Check that extractall was called once to extract the zip file contents
|
||||
mock_zipfile_instance.extractall.assert_called_once_with("tmp")
|
||||
|
||||
# Check that the import_table function was called for each table
|
||||
table_names = [
|
||||
"User",
|
||||
"Contact",
|
||||
"Domain",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"UserDomainRole",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"PublicContact",
|
||||
]
|
||||
# Check that os.path.exists was called for each table
|
||||
for table_name in table_names:
|
||||
mock_path_exists.assert_any_call(f"tmp/{table_name}.csv")
|
||||
mock_path_exists.assert_any_call(f"{table_name}_1.csv")
|
||||
|
||||
# Check that clean_tables is called for Contact
|
||||
mock_get_model.assert_any_call("registrar", "Contact")
|
||||
|
@ -1079,18 +1083,18 @@ class TestImportTables(TestCase):
|
|||
|
||||
# Check that logger.info was called for each successful import
|
||||
for table_name in table_names:
|
||||
mock_logger.info.assert_any_call(f"Successfully imported tmp/{table_name}.csv into {table_name}")
|
||||
mock_logger.info.assert_any_call(f"Successfully imported {table_name}_1.csv into {table_name}")
|
||||
|
||||
# Check that logger.error was not called for resource class not found
|
||||
mock_logger.error.assert_not_called()
|
||||
|
||||
# Check that os.remove was called for each CSV file
|
||||
for table_name in table_names:
|
||||
mock_remove.assert_any_call(f"tmp/{table_name}.csv")
|
||||
mock_remove.assert_any_call(f"{table_name}_1.csv")
|
||||
|
||||
# Check that logger.info was called for each CSV file removal
|
||||
for table_name in table_names:
|
||||
mock_logger.info.assert_any_call(f"Removed temporary file tmp/{table_name}.csv")
|
||||
mock_logger.info.assert_any_call(f"Removed temporary file {table_name}_1.csv")
|
||||
|
||||
@patch("registrar.management.commands.import_tables.logger")
|
||||
@patch("registrar.management.commands.import_tables.os.makedirs")
|
||||
|
|
|
@ -24,6 +24,7 @@ SAMPLE_KWARGS = {
|
|||
"object_id": "3",
|
||||
"domain": "whitehouse.gov",
|
||||
"user_pk": "1",
|
||||
"portfolio_id": "1",
|
||||
}
|
||||
|
||||
# Our test suite will ignore some namespaces.
|
||||
|
|
|
@ -8,6 +8,7 @@ from api.tests.common import less_console_noise_decorator
|
|||
from registrar.models.contact import Contact
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.draft_domain import DraftDomain
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.public_contact import PublicContact
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
|
@ -664,7 +665,6 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
|
|||
super().tearDown()
|
||||
PublicContact.objects.filter(domain=self.domain).delete()
|
||||
self.role.delete()
|
||||
self.domain.delete()
|
||||
Domain.objects.all().delete()
|
||||
Website.objects.all().delete()
|
||||
Contact.objects.all().delete()
|
||||
|
@ -918,3 +918,77 @@ class UserProfileTests(TestWithUser, WebTest):
|
|||
profile_page = profile_page.follow()
|
||||
self.assertEqual(profile_page.status_code, 200)
|
||||
self.assertContains(profile_page, "Your profile has been updated")
|
||||
|
||||
|
||||
class PortfoliosTests(TestWithUser, WebTest):
|
||||
"""A series of tests that target the organizations"""
|
||||
|
||||
# csrf checks do not work well with WebTest.
|
||||
# We disable them here.
|
||||
csrf_checks = False
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user.save()
|
||||
self.client.force_login(self.user)
|
||||
self.domain, _ = Domain.objects.get_or_create(name="sampledomain.gov", state=Domain.State.READY)
|
||||
self.role, _ = UserDomainRole.objects.get_or_create(
|
||||
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="xyz inc")
|
||||
|
||||
def tearDown(self):
|
||||
Portfolio.objects.all().delete()
|
||||
super().tearDown()
|
||||
PublicContact.objects.filter(domain=self.domain).delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
Website.objects.all().delete()
|
||||
Contact.objects.all().delete()
|
||||
|
||||
def _set_session_cookie(self):
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_middleware_redirects_to_portfolio_homepage(self):
|
||||
"""Tests that a user is redirected to the portfolio homepage when organization_feature is on and
|
||||
a portfolio belongs to the user, test for the special h1s which only exist in that version
|
||||
of the homepage"""
|
||||
self.app.set_user(self.user.username)
|
||||
with override_flag("organization_feature", active=True):
|
||||
# This will redirect the user to the portfolio page.
|
||||
# Follow implicity checks if our redirect is working.
|
||||
portfolio_page = self.app.get(reverse("home")).follow()
|
||||
self._set_session_cookie()
|
||||
|
||||
# Assert that we're on the right page
|
||||
self.assertContains(portfolio_page, self.portfolio.organization_name)
|
||||
|
||||
self.assertContains(portfolio_page, "<h1>Domains</h1>")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_no_redirect_when_org_flag_false(self):
|
||||
"""No redirect so no follow,
|
||||
implicitely test for the presense of the h2 by looking up its id"""
|
||||
self.app.set_user(self.user.username)
|
||||
home_page = self.app.get(reverse("home"))
|
||||
self._set_session_cookie()
|
||||
|
||||
self.assertNotContains(home_page, self.portfolio.organization_name)
|
||||
|
||||
self.assertContains(home_page, 'id="domain-requests-header"')
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_no_redirect_when_user_has_no_portfolios(self):
|
||||
"""No redirect so no follow,
|
||||
implicitely test for the presense of the h2 by looking up its id"""
|
||||
self.portfolio.delete()
|
||||
self.app.set_user(self.user.username)
|
||||
with override_flag("organization_feature", active=True):
|
||||
home_page = self.app.get(reverse("home"))
|
||||
self._set_session_cookie()
|
||||
|
||||
self.assertNotContains(home_page, self.portfolio.organization_name)
|
||||
|
||||
self.assertContains(home_page, 'id="domain-requests-header"')
|
||||
|
|
|
@ -9,6 +9,7 @@ def index(request):
|
|||
if request.user.is_authenticated:
|
||||
# This is a django waffle flag which toggles features based off of the "flag" table
|
||||
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
|
||||
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
|
||||
|
||||
# This controls the creation of a new domain request in the wizard
|
||||
request.session["new_request"] = True
|
||||
|
|
39
src/registrar/views/portfolios.py
Normal file
39
src/registrar/views/portfolios.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from django.shortcuts import get_object_or_404, render
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from waffle.decorators import flag_is_active
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
|
||||
@login_required
|
||||
def portfolio_domains(request, portfolio_id):
|
||||
context = {}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
# This is a django waffle flag which toggles features based off of the "flag" table
|
||||
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
|
||||
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
|
||||
|
||||
# Retrieve the portfolio object based on the provided portfolio_id
|
||||
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
|
||||
context["portfolio"] = portfolio
|
||||
|
||||
return render(request, "portfolio_domains.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def portfolio_domain_requests(request, portfolio_id):
|
||||
context = {}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
# This is a django waffle flag which toggles features based off of the "flag" table
|
||||
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
|
||||
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
|
||||
|
||||
# Retrieve the portfolio object based on the provided portfolio_id
|
||||
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
|
||||
context["portfolio"] = portfolio
|
||||
|
||||
# This controls the creation of a new domain request in the wizard
|
||||
request.session["new_request"] = True
|
||||
|
||||
return render(request, "portfolio_requests.html", context)
|
Loading…
Add table
Add a link
Reference in a new issue