mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-17 18:09:25 +02:00
Merge branch 'main' into za/2332-display-action-needed-text
This commit is contained in:
commit
be88ef1238
32 changed files with 785 additions and 410 deletions
|
@ -697,3 +697,31 @@ Example: `cf ssh getgov-za`
|
||||||
| | Parameter | Description |
|
| | Parameter | Description |
|
||||||
|:-:|:-------------------------- |:----------------------------------------------------------------------------|
|
|:-:|:-------------------------- |:----------------------------------------------------------------------------|
|
||||||
| 1 | **debug** | Increases logging detail. Defaults to False. |
|
| 1 | **debug** | Increases logging detail. Defaults to False. |
|
||||||
|
|
||||||
|
## Email current metadata report
|
||||||
|
|
||||||
|
### Running on sandboxes
|
||||||
|
|
||||||
|
#### Step 1: Login to CloudFoundry
|
||||||
|
```cf login -a api.fr.cloud.gov --sso```
|
||||||
|
|
||||||
|
#### Step 2: SSH into your environment
|
||||||
|
```cf ssh getgov-{space}```
|
||||||
|
|
||||||
|
Example: `cf ssh getgov-za`
|
||||||
|
|
||||||
|
#### Step 3: Create a shell instance
|
||||||
|
```/tmp/lifecycle/shell```
|
||||||
|
|
||||||
|
#### Step 4: Running the script
|
||||||
|
```./manage.py email_current_metadata_report --emailTo {desired email address}```
|
||||||
|
|
||||||
|
### Running locally
|
||||||
|
|
||||||
|
#### Step 1: Running the script
|
||||||
|
```docker-compose exec app ./manage.py email_current_metadata_report --emailTo {desired email address}```
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
| | Parameter | Description |
|
||||||
|
|:-:|:-------------------------- |:-----------------------------------------------------------------------------------|
|
||||||
|
| 1 | **emailTo** | Specifies where the email will be emailed. Defaults to help@get.gov on production. |
|
|
@ -2763,6 +2763,14 @@ class WaffleFlagAdmin(FlagAdmin):
|
||||||
fields = "__all__"
|
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.unregister(LogEntry) # Unregister the default registration
|
||||||
|
|
||||||
admin.site.register(LogEntry, CustomLogEntryAdmin)
|
admin.site.register(LogEntry, CustomLogEntryAdmin)
|
||||||
|
@ -2786,6 +2794,8 @@ admin.site.register(models.DomainRequest, DomainRequestAdmin)
|
||||||
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
||||||
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
|
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
|
||||||
admin.site.register(models.Portfolio, PortfolioAdmin)
|
admin.site.register(models.Portfolio, PortfolioAdmin)
|
||||||
|
admin.site.register(models.DomainGroup, DomainGroupAdmin)
|
||||||
|
admin.site.register(models.Suborganization, SuborganizationAdmin)
|
||||||
|
|
||||||
# Register our custom waffle implementations
|
# Register our custom waffle implementations
|
||||||
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
||||||
|
|
|
@ -189,6 +189,7 @@ MIDDLEWARE = [
|
||||||
# Used for waffle feature flags
|
# Used for waffle feature flags
|
||||||
"waffle.middleware.WaffleMiddleware",
|
"waffle.middleware.WaffleMiddleware",
|
||||||
"registrar.registrar_middleware.CheckUserProfileMiddleware",
|
"registrar.registrar_middleware.CheckUserProfileMiddleware",
|
||||||
|
"registrar.registrar_middleware.CheckPortfolioMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
# application object used by Django’s built-in servers (e.g. `runserver`)
|
# application object used by Django’s built-in servers (e.g. `runserver`)
|
||||||
|
|
|
@ -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.domain_requests_json import get_domain_requests_json
|
||||||
from registrar.views.domains_json import get_domains_json
|
from registrar.views.domains_json import get_domains_json
|
||||||
from registrar.views.utility import always_404
|
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
|
from api.views import available, get_current_federal, get_current_full
|
||||||
|
|
||||||
|
|
||||||
|
@ -58,6 +59,16 @@ for step, view in [
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.index, name="home"),
|
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(
|
path(
|
||||||
"admin/logout/",
|
"admin/logout/",
|
||||||
RedirectView.as_view(pattern_name="logout", permanent=False),
|
RedirectView.as_view(pattern_name="logout", permanent=False),
|
||||||
|
|
|
@ -10,6 +10,8 @@ from registrar.models.utility.domain_helper import DomainHelper
|
||||||
class UserProfileForm(forms.ModelForm):
|
class UserProfileForm(forms.ModelForm):
|
||||||
"""Form for updating user profile."""
|
"""Form for updating user profile."""
|
||||||
|
|
||||||
|
redirect = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Contact
|
model = Contact
|
||||||
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
|
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
"""Generates current-metadata.csv then uploads to S3 + sends email"""
|
"""Generates current-metadata.csv then uploads to S3 + sends email"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import pyzipper
|
import pyzipper
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
@ -9,7 +8,7 @@ from datetime import datetime
|
||||||
from django.core.management import BaseCommand
|
from django.core.management import BaseCommand
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from registrar.utility import csv_export
|
from registrar.utility import csv_export
|
||||||
from registrar.utility.s3_bucket import S3ClientHelper
|
from io import StringIO
|
||||||
from ...utility.email import send_templated_email
|
from ...utility.email import send_templated_email
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,89 +16,101 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
"""Emails a encrypted zip file containing a csv of our domains and domain requests"""
|
||||||
|
|
||||||
help = (
|
help = (
|
||||||
"Generates and uploads a domain-metadata.csv file to our S3 bucket "
|
"Generates and uploads a domain-metadata.csv file to our S3 bucket "
|
||||||
"which is based off of all existing Domains."
|
"which is based off of all existing Domains."
|
||||||
)
|
)
|
||||||
|
current_date = datetime.now().strftime("%m%d%Y")
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
"""Add our two filename arguments."""
|
"""Add our two filename arguments."""
|
||||||
parser.add_argument("--directory", default="migrationdata", help="Desired directory")
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--checkpath",
|
"--emailTo",
|
||||||
default=True,
|
default=settings.DEFAULT_FROM_EMAIL,
|
||||||
help="Flag that determines if we do a check for os.path.exists. Used for test cases",
|
help="Defines where we should email this report",
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, **options):
|
def handle(self, **options):
|
||||||
"""Grabs the directory then creates domain-metadata.csv in that directory"""
|
"""Grabs the directory then creates domain-metadata.csv in that directory"""
|
||||||
file_name = "domain-metadata.csv"
|
zip_filename = f"domain-metadata-{self.current_date}.zip"
|
||||||
# Ensures a slash is added
|
email_to = options.get("emailTo")
|
||||||
directory = os.path.join(options.get("directory"), "")
|
|
||||||
check_path = options.get("checkpath")
|
# Don't email to DEFAULT_FROM_EMAIL when not prod.
|
||||||
|
if not settings.IS_PRODUCTION and email_to == settings.DEFAULT_FROM_EMAIL:
|
||||||
|
raise ValueError(
|
||||||
|
"The --emailTo arg must be specified in non-prod environments, "
|
||||||
|
"and the arg must not equal the DEFAULT_FROM_EMAIL value (aka: help@get.gov)."
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("Generating report...")
|
logger.info("Generating report...")
|
||||||
try:
|
try:
|
||||||
self.email_current_metadata_report(directory, file_name, check_path)
|
self.email_current_metadata_report(zip_filename, email_to)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
# TODO - #1317: Notify operations when auto report generation fails
|
# TODO - #1317: Notify operations when auto report generation fails
|
||||||
raise err
|
raise err
|
||||||
else:
|
else:
|
||||||
logger.info(f"Success! Created {file_name} and successfully sent out an email!")
|
logger.info(f"Success! Created {zip_filename} and successfully sent out an email!")
|
||||||
|
|
||||||
def email_current_metadata_report(self, directory, file_name, check_path):
|
def email_current_metadata_report(self, zip_filename, email_to):
|
||||||
"""Creates a current-metadata.csv file under the specified directory,
|
"""Emails a password protected zip containing domain-metadata and domain-request-metadata"""
|
||||||
then uploads it to a AWS S3 bucket. This is done for resiliency
|
reports = {
|
||||||
reasons in the event our application goes down and/or the email
|
"Domain report": {
|
||||||
cannot send -- we'll still be able to grab info from the S3
|
"report_filename": f"domain-metadata-{self.current_date}.csv",
|
||||||
instance"""
|
"report_function": csv_export.export_data_type_to_csv,
|
||||||
s3_client = S3ClientHelper()
|
},
|
||||||
file_path = os.path.join(directory, file_name)
|
"Domain request report": {
|
||||||
|
"report_filename": f"domain-request-metadata-{self.current_date}.csv",
|
||||||
|
"report_function": csv_export.DomainRequestExport.export_full_domain_request_report,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Generate a file locally for upload
|
# Set the password equal to our content in SECRET_ENCRYPT_METADATA.
|
||||||
with open(file_path, "w") as file:
|
# For local development, this will be "devpwd" unless otherwise set.
|
||||||
csv_export.export_data_type_to_csv(file)
|
# Uncomment these lines if you want to use this:
|
||||||
|
# override = settings.SECRET_ENCRYPT_METADATA is None and not settings.IS_PRODUCTION
|
||||||
|
# password = "devpwd" if override else settings.SECRET_ENCRYPT_METADATA
|
||||||
|
password = settings.SECRET_ENCRYPT_METADATA
|
||||||
|
if not password:
|
||||||
|
raise ValueError("No password was specified for this zip file.")
|
||||||
|
|
||||||
if check_path and not os.path.exists(file_path):
|
encrypted_zip_in_bytes = self.get_encrypted_zip(zip_filename, reports, password)
|
||||||
raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")
|
|
||||||
|
|
||||||
s3_client.upload_file(file_path, file_name)
|
|
||||||
|
|
||||||
# Set zip file name
|
|
||||||
current_date = datetime.now().strftime("%m%d%Y")
|
|
||||||
current_filename = f"domain-metadata-{current_date}.zip"
|
|
||||||
|
|
||||||
# Pre-set zip file name
|
|
||||||
encrypted_metadata_output = current_filename
|
|
||||||
|
|
||||||
# Set context for the subject
|
|
||||||
current_date_str = datetime.now().strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
# Encrypt the metadata
|
|
||||||
encrypted_metadata_in_bytes = self._encrypt_metadata(
|
|
||||||
s3_client.get_file(file_name), encrypted_metadata_output, str.encode(settings.SECRET_ENCRYPT_METADATA)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send the metadata file that is zipped
|
# Send the metadata file that is zipped
|
||||||
send_templated_email(
|
send_templated_email(
|
||||||
template_name="emails/metadata_body.txt",
|
template_name="emails/metadata_body.txt",
|
||||||
subject_template_name="emails/metadata_subject.txt",
|
subject_template_name="emails/metadata_subject.txt",
|
||||||
to_address=settings.DEFAULT_FROM_EMAIL,
|
to_address=email_to,
|
||||||
context={"current_date_str": current_date_str},
|
context={"current_date_str": datetime.now().strftime("%Y-%m-%d")},
|
||||||
attachment_file=encrypted_metadata_in_bytes,
|
attachment_file=encrypted_zip_in_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _encrypt_metadata(self, input_file, output_file, password):
|
def get_encrypted_zip(self, zip_filename, reports, password):
|
||||||
"""Helper function for encrypting the attachment file"""
|
"""Helper function for encrypting the attachment file"""
|
||||||
current_date = datetime.now().strftime("%m%d%Y")
|
|
||||||
current_filename = f"domain-metadata-{current_date}.csv"
|
|
||||||
# Using ZIP_DEFLATED bc it's a more common compression method supported by most zip utilities and faster
|
# Using ZIP_DEFLATED bc it's a more common compression method supported by most zip utilities and faster
|
||||||
# We could also use compression=pyzipper.ZIP_LZMA if we are looking for smaller file size
|
# We could also use compression=pyzipper.ZIP_LZMA if we are looking for smaller file size
|
||||||
with pyzipper.AESZipFile(
|
with pyzipper.AESZipFile(
|
||||||
output_file, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
|
zip_filename, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
|
||||||
) as f_out:
|
) as f_out:
|
||||||
f_out.setpassword(password)
|
f_out.setpassword(str.encode(password))
|
||||||
f_out.writestr(current_filename, input_file)
|
for report_name, report in reports.items():
|
||||||
with open(output_file, "rb") as file_data:
|
logger.info(f"Generating {report_name}")
|
||||||
|
report_content = self.write_and_return_report(report["report_function"])
|
||||||
|
f_out.writestr(report["report_filename"], report_content)
|
||||||
|
|
||||||
|
# Get the final report for emailing purposes
|
||||||
|
with open(zip_filename, "rb") as file_data:
|
||||||
attachment_in_bytes = file_data.read()
|
attachment_in_bytes = file_data.read()
|
||||||
|
|
||||||
return attachment_in_bytes
|
return attachment_in_bytes
|
||||||
|
|
||||||
|
def write_and_return_report(self, report_function):
|
||||||
|
"""Writes a report to a StringIO object given a report_function and returns the string."""
|
||||||
|
report_bytes = StringIO()
|
||||||
|
report_function(report_bytes)
|
||||||
|
|
||||||
|
# Rewind the buffer to the beginning after writing
|
||||||
|
report_bytes.seek(0)
|
||||||
|
return report_bytes.read()
|
||||||
|
|
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,
|
||||||
|
),
|
||||||
|
]
|
|
@ -17,6 +17,8 @@ from .transition_domain import TransitionDomain
|
||||||
from .verified_by_staff import VerifiedByStaff
|
from .verified_by_staff import VerifiedByStaff
|
||||||
from .waffle_flag import WaffleFlag
|
from .waffle_flag import WaffleFlag
|
||||||
from .portfolio import Portfolio
|
from .portfolio import Portfolio
|
||||||
|
from .domain_group import DomainGroup
|
||||||
|
from .suborganization import Suborganization
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -38,6 +40,8 @@ __all__ = [
|
||||||
"VerifiedByStaff",
|
"VerifiedByStaff",
|
||||||
"WaffleFlag",
|
"WaffleFlag",
|
||||||
"Portfolio",
|
"Portfolio",
|
||||||
|
"DomainGroup",
|
||||||
|
"Suborganization",
|
||||||
]
|
]
|
||||||
|
|
||||||
auditlog.register(Contact)
|
auditlog.register(Contact)
|
||||||
|
@ -58,3 +62,5 @@ auditlog.register(TransitionDomain)
|
||||||
auditlog.register(VerifiedByStaff)
|
auditlog.register(VerifiedByStaff)
|
||||||
auditlog.register(WaffleFlag)
|
auditlog.register(WaffleFlag)
|
||||||
auditlog.register(Portfolio)
|
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}"
|
|
@ -17,6 +17,8 @@ from .utility.time_stamped_model import TimeStampedModel
|
||||||
from ..utility.email import send_templated_email, EmailSendingError
|
from ..utility.email import send_templated_email, EmailSendingError
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
||||||
|
from waffle.decorators import flag_is_active
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1170,19 +1172,21 @@ class DomainRequest(TimeStampedModel):
|
||||||
def _is_policy_acknowledgement_complete(self):
|
def _is_policy_acknowledgement_complete(self):
|
||||||
return self.is_policy_acknowledged is not None
|
return self.is_policy_acknowledged is not None
|
||||||
|
|
||||||
def _is_general_form_complete(self):
|
def _is_general_form_complete(self, request):
|
||||||
|
has_profile_feature_flag = flag_is_active(request, "profile_feature")
|
||||||
return (
|
return (
|
||||||
self._is_organization_name_and_address_complete()
|
self._is_organization_name_and_address_complete()
|
||||||
and self._is_authorizing_official_complete()
|
and self._is_authorizing_official_complete()
|
||||||
and self._is_requested_domain_complete()
|
and self._is_requested_domain_complete()
|
||||||
and self._is_purpose_complete()
|
and self._is_purpose_complete()
|
||||||
and self._is_submitter_complete()
|
# NOTE: This flag leaves submitter as empty (request wont submit) hence preset to True
|
||||||
|
and (self._is_submitter_complete() if not has_profile_feature_flag else True)
|
||||||
and self._is_other_contacts_complete()
|
and self._is_other_contacts_complete()
|
||||||
and self._is_additional_details_complete()
|
and self._is_additional_details_complete()
|
||||||
and self._is_policy_acknowledgement_complete()
|
and self._is_policy_acknowledgement_complete()
|
||||||
)
|
)
|
||||||
|
|
||||||
def _form_complete(self):
|
def _form_complete(self, request):
|
||||||
match self.generic_org_type:
|
match self.generic_org_type:
|
||||||
case DomainRequest.OrganizationChoices.FEDERAL:
|
case DomainRequest.OrganizationChoices.FEDERAL:
|
||||||
is_complete = self._is_federal_complete()
|
is_complete = self._is_federal_complete()
|
||||||
|
@ -1203,8 +1207,6 @@ class DomainRequest(TimeStampedModel):
|
||||||
case _:
|
case _:
|
||||||
# NOTE: Shouldn't happen, this is only if somehow they didn't choose an org type
|
# NOTE: Shouldn't happen, this is only if somehow they didn't choose an org type
|
||||||
is_complete = False
|
is_complete = False
|
||||||
|
if not is_complete or not self._is_general_form_complete(request):
|
||||||
if not is_complete or not self._is_general_form_complete():
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
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
|
Contains middleware used in settings.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
|
from registrar.models.portfolio import Portfolio
|
||||||
from registrar.models.user import User
|
from registrar.models.user import User
|
||||||
from waffle.decorators import flag_is_active
|
from waffle.decorators import flag_is_active
|
||||||
|
|
||||||
from registrar.models.utility.generic_helper import replace_url_queryparams
|
from registrar.models.utility.generic_helper import replace_url_queryparams
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class NoCacheMiddleware:
|
class NoCacheMiddleware:
|
||||||
"""
|
"""
|
||||||
|
@ -41,6 +45,7 @@ class CheckUserProfileMiddleware:
|
||||||
self.setup_page = reverse("finish-user-profile-setup")
|
self.setup_page = reverse("finish-user-profile-setup")
|
||||||
self.profile_page = reverse("user-profile")
|
self.profile_page = reverse("user-profile")
|
||||||
self.logout_page = reverse("logout")
|
self.logout_page = reverse("logout")
|
||||||
|
|
||||||
self.regular_excluded_pages = [
|
self.regular_excluded_pages = [
|
||||||
self.setup_page,
|
self.setup_page,
|
||||||
self.logout_page,
|
self.logout_page,
|
||||||
|
@ -52,6 +57,14 @@ class CheckUserProfileMiddleware:
|
||||||
"/admin",
|
"/admin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
self.excluded_pages = {
|
||||||
|
self.setup_page: self.regular_excluded_pages,
|
||||||
|
self.profile_page: self.other_excluded_pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_excluded_pages(self, page):
|
||||||
|
return self.excluded_pages.get(page, [])
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
return response
|
return response
|
||||||
|
@ -68,16 +81,16 @@ class CheckUserProfileMiddleware:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
if hasattr(request.user, "finished_setup") and not request.user.finished_setup:
|
profile_page = self.profile_page
|
||||||
if request.user.verification_type == User.VerificationTypeChoices.REGULAR:
|
if request.user.verification_type == User.VerificationTypeChoices.REGULAR:
|
||||||
return self._handle_regular_user_setup_not_finished(request)
|
profile_page = self.setup_page
|
||||||
else:
|
if hasattr(request.user, "finished_setup") and not request.user.finished_setup:
|
||||||
return self._handle_other_user_setup_not_finished(request)
|
return self._handle_user_setup_not_finished(request, profile_page)
|
||||||
|
|
||||||
# Continue processing the view
|
# Continue processing the view
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _handle_regular_user_setup_not_finished(self, request):
|
def _handle_user_setup_not_finished(self, request, profile_page):
|
||||||
"""Redirects the given user to the finish setup page.
|
"""Redirects the given user to the finish setup page.
|
||||||
|
|
||||||
We set the "redirect" query param equal to where the user wants to go.
|
We set the "redirect" query param equal to where the user wants to go.
|
||||||
|
@ -93,7 +106,7 @@ class CheckUserProfileMiddleware:
|
||||||
custom_redirect = "domain-request:" if request.path == "/request/" else None
|
custom_redirect = "domain-request:" if request.path == "/request/" else None
|
||||||
|
|
||||||
# Don't redirect on excluded pages (such as the setup page itself)
|
# Don't redirect on excluded pages (such as the setup page itself)
|
||||||
if not any(request.path.startswith(page) for page in self.regular_excluded_pages):
|
if not any(request.path.startswith(page) for page in self._get_excluded_pages(profile_page)):
|
||||||
|
|
||||||
# Preserve the original query parameters, and coerce them into a dict
|
# Preserve the original query parameters, and coerce them into a dict
|
||||||
query_params = parse_qs(request.META["QUERY_STRING"])
|
query_params = parse_qs(request.META["QUERY_STRING"])
|
||||||
|
@ -103,19 +116,39 @@ class CheckUserProfileMiddleware:
|
||||||
query_params["redirect"] = custom_redirect
|
query_params["redirect"] = custom_redirect
|
||||||
|
|
||||||
# Add our new query param, while preserving old ones
|
# Add our new query param, while preserving old ones
|
||||||
new_setup_page = replace_url_queryparams(self.setup_page, query_params) if query_params else self.setup_page
|
new_setup_page = replace_url_queryparams(profile_page, query_params) if query_params else profile_page
|
||||||
|
|
||||||
return HttpResponseRedirect(new_setup_page)
|
return HttpResponseRedirect(new_setup_page)
|
||||||
else:
|
else:
|
||||||
# Process the view as normal
|
# Process the view as normal
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _handle_other_user_setup_not_finished(self, request):
|
|
||||||
"""Redirects the given user to the profile page to finish setup."""
|
|
||||||
|
|
||||||
# Don't redirect on excluded pages (such as the setup page itself)
|
class CheckPortfolioMiddleware:
|
||||||
if not any(request.path.startswith(page) for page in self.other_excluded_pages):
|
"""
|
||||||
return HttpResponseRedirect(self.profile_page)
|
Checks if the current user has a portfolio
|
||||||
else:
|
If they do, redirect them to the portfolio homepage when they navigate to home.
|
||||||
# Process the view as normal
|
"""
|
||||||
|
|
||||||
|
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
|
return None
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
completing your domain request might take around 15 minutes.</p>
|
completing your domain request might take around 15 minutes.</p>
|
||||||
{% if has_profile_feature_flag %}
|
{% if has_profile_feature_flag %}
|
||||||
<h2>How we’ll reach you</h2>
|
<h2>How we’ll reach you</h2>
|
||||||
<p>While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?return_to_request=True" class="usa-link">your profile</a> to make updates.</p>
|
<p>While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?redirect=domain-request:" class="usa-link">your profile</a> to make updates.</p>
|
||||||
{% include "includes/profile_information.html" with user=user%}
|
{% include "includes/profile_information.html" with user=user%}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
{# Disable the redirect #}
|
{# Disable the redirect #}
|
||||||
{% block logo %}
|
{% block logo %}
|
||||||
{% include "includes/gov_extended_logo.html" with logo_clickable=confirm_changes %}
|
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{# Add the new form #}
|
{# Add the new form #}
|
||||||
|
@ -16,5 +16,5 @@
|
||||||
{% endblock content_bottom %}
|
{% endblock content_bottom %}
|
||||||
|
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
{% include "includes/footer.html" with show_manage_your_domains=confirm_changes %}
|
{% include "includes/footer.html" with show_manage_your_domains=user_finished_setup %}
|
||||||
{% endblock footer %}
|
{% endblock footer %}
|
||||||
|
|
|
@ -9,11 +9,13 @@
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
{# the entire logged in page goes here #}
|
{# the entire logged in page goes here #}
|
||||||
|
|
||||||
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
|
{% block homepage_content %}
|
||||||
|
|
||||||
|
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
|
||||||
{% block messages %}
|
{% block messages %}
|
||||||
{% include "includes/form_messages.html" %}
|
{% include "includes/form_messages.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<h1>Manage your domains</h2>
|
<h1>Manage your domains</h1>
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
IMPORTANT:
|
IMPORTANT:
|
||||||
|
@ -27,153 +29,8 @@
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="section--outlined domains">
|
{% include "includes/domains_table.html" %}
|
||||||
<div class="grid-row">
|
{% include "includes/domain_requests_table.html" %}
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{# Note: Reimplement this after MVP #}
|
{# Note: Reimplement this after MVP #}
|
||||||
<!--
|
<!--
|
||||||
|
@ -192,6 +49,8 @@
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% else %} {# not user.is_authenticated #}
|
{% 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>
|
|
@ -33,6 +33,8 @@
|
||||||
Your contact information
|
Your contact information
|
||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
|
<input type="hidden" name="redirect" value="{{ form.initial.redirect }}">
|
||||||
|
|
||||||
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
|
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
|
||||||
{% input_with_errors form.full_name %}
|
{% input_with_errors form.full_name %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
@ -78,7 +80,7 @@
|
||||||
<button type="submit" name="contact_setup_save_button" class="usa-button ">
|
<button type="submit" name="contact_setup_save_button" class="usa-button ">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
{% if confirm_changes and going_to_specific_page %}
|
{% if user_finished_setup and going_to_specific_page %}
|
||||||
<button type="submit" name="contact_setup_submit_button" class="usa-button usa-button--outline">
|
<button type="submit" name="contact_setup_submit_button" class="usa-button usa-button--outline">
|
||||||
{{redirect_button_text }}
|
{{redirect_button_text }}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -19,6 +19,9 @@
|
||||||
<form class="usa-form usa-form--large" method="post" novalidate>
|
<form class="usa-form usa-form--large" method="post" novalidate>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{# Include the hidden 'redirect' field #}
|
||||||
|
<input type="hidden" name="redirect" value="{{ form.initial.redirect }}">
|
||||||
|
|
||||||
{% input_with_errors form.first_name %}
|
{% input_with_errors form.first_name %}
|
||||||
|
|
||||||
{% input_with_errors form.middle_name %}
|
{% input_with_errors form.middle_name %}
|
||||||
|
|
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>
|
|
@ -25,19 +25,13 @@ Edit your User Profile |
|
||||||
{% include "includes/form_errors.html" with form=form %}
|
{% include "includes/form_errors.html" with form=form %}
|
||||||
|
|
||||||
{% if show_back_button %}
|
{% if show_back_button %}
|
||||||
<a href="{% if not return_to_request %}{% url 'home' %}{% else %}{% url 'domain-request:' %}{% endif %}" class="breadcrumb__back">
|
<a href="{% url form.initial.redirect %}" class="breadcrumb__back">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||||
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
|
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
|
||||||
</svg>
|
</svg>
|
||||||
{% if not return_to_request %}
|
|
||||||
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
|
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
|
||||||
{{ profile_back_button_text }}
|
{{ profile_back_button_text }}
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
|
||||||
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
|
|
||||||
Go back to your domain request
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@ from django.db.utils import IntegrityError
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from django.test import RequestFactory
|
||||||
|
|
||||||
from registrar.models import (
|
from registrar.models import (
|
||||||
Contact,
|
Contact,
|
||||||
DomainRequest,
|
DomainRequest,
|
||||||
|
@ -1610,6 +1612,7 @@ class TestDomainInformationCustomSave(TestCase):
|
||||||
class TestDomainRequestIncomplete(TestCase):
|
class TestDomainRequestIncomplete(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
self.factory = RequestFactory()
|
||||||
username = "test_user"
|
username = "test_user"
|
||||||
first_name = "First"
|
first_name = "First"
|
||||||
last_name = "Last"
|
last_name = "Last"
|
||||||
|
@ -2013,7 +2016,10 @@ class TestDomainRequestIncomplete(TestCase):
|
||||||
self.assertFalse(self.domain_request._is_policy_acknowledgement_complete())
|
self.assertFalse(self.domain_request._is_policy_acknowledgement_complete())
|
||||||
|
|
||||||
def test_form_complete(self):
|
def test_form_complete(self):
|
||||||
self.assertTrue(self.domain_request._form_complete())
|
request = self.factory.get("/")
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
self.assertTrue(self.domain_request._form_complete(request))
|
||||||
self.domain_request.generic_org_type = None
|
self.domain_request.generic_org_type = None
|
||||||
self.domain_request.save()
|
self.domain_request.save()
|
||||||
self.assertFalse(self.domain_request._form_complete())
|
self.assertFalse(self.domain_request._form_complete(request))
|
||||||
|
|
|
@ -24,6 +24,7 @@ SAMPLE_KWARGS = {
|
||||||
"object_id": "3",
|
"object_id": "3",
|
||||||
"domain": "whitehouse.gov",
|
"domain": "whitehouse.gov",
|
||||||
"user_pk": "1",
|
"user_pk": "1",
|
||||||
|
"portfolio_id": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Our test suite will ignore some namespaces.
|
# 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.contact import Contact
|
||||||
from registrar.models.domain import Domain
|
from registrar.models.domain import Domain
|
||||||
from registrar.models.draft_domain import DraftDomain
|
from registrar.models.draft_domain import DraftDomain
|
||||||
|
from registrar.models.portfolio import Portfolio
|
||||||
from registrar.models.public_contact import PublicContact
|
from registrar.models.public_contact import PublicContact
|
||||||
from registrar.models.user import User
|
from registrar.models.user import User
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
|
@ -530,7 +531,10 @@ class FinishUserProfileTests(TestWithUser, WebTest):
|
||||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
|
||||||
def _submit_form_webtest(self, form, follow=False):
|
def _submit_form_webtest(self, form, follow=False, name=None):
|
||||||
|
if name:
|
||||||
|
page = form.submit(name=name)
|
||||||
|
else:
|
||||||
page = form.submit()
|
page = form.submit()
|
||||||
self._set_session_cookie()
|
self._set_session_cookie()
|
||||||
return page.follow() if follow else page
|
return page.follow() if follow else page
|
||||||
|
@ -605,6 +609,15 @@ class FinishUserProfileTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
self.assertEqual(completed_setup_page.status_code, 200)
|
self.assertEqual(completed_setup_page.status_code, 200)
|
||||||
|
|
||||||
|
finish_setup_form = completed_setup_page.form
|
||||||
|
|
||||||
|
# Submit the form using the specific submit button to execute the redirect
|
||||||
|
completed_setup_page = self._submit_form_webtest(
|
||||||
|
finish_setup_form, follow=True, name="contact_setup_submit_button"
|
||||||
|
)
|
||||||
|
self.assertEqual(completed_setup_page.status_code, 200)
|
||||||
|
|
||||||
|
# Assert that we are still on the
|
||||||
# Assert that we're on the domain request page
|
# Assert that we're on the domain request page
|
||||||
self.assertNotContains(completed_setup_page, "Finish setting up your profile")
|
self.assertNotContains(completed_setup_page, "Finish setting up your profile")
|
||||||
self.assertNotContains(completed_setup_page, "What contact information should we use to reach you?")
|
self.assertNotContains(completed_setup_page, "What contact information should we use to reach you?")
|
||||||
|
@ -652,7 +665,6 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
PublicContact.objects.filter(domain=self.domain).delete()
|
PublicContact.objects.filter(domain=self.domain).delete()
|
||||||
self.role.delete()
|
self.role.delete()
|
||||||
self.domain.delete()
|
|
||||||
Domain.objects.all().delete()
|
Domain.objects.all().delete()
|
||||||
Website.objects.all().delete()
|
Website.objects.all().delete()
|
||||||
Contact.objects.all().delete()
|
Contact.objects.all().delete()
|
||||||
|
@ -822,7 +834,7 @@ class UserProfileTests(TestWithUser, WebTest):
|
||||||
"""tests user profile when profile_feature is on,
|
"""tests user profile when profile_feature is on,
|
||||||
and when they are redirected from the domain request page"""
|
and when they are redirected from the domain request page"""
|
||||||
with override_flag("profile_feature", active=True):
|
with override_flag("profile_feature", active=True):
|
||||||
response = self.client.get("/user-profile?return_to_request=True")
|
response = self.client.get("/user-profile?redirect=domain-request:")
|
||||||
self.assertContains(response, "Your profile")
|
self.assertContains(response, "Your profile")
|
||||||
self.assertContains(response, "Go back to your domain request")
|
self.assertContains(response, "Go back to your domain request")
|
||||||
self.assertNotContains(response, "Back to manage your domains")
|
self.assertNotContains(response, "Back to manage your domains")
|
||||||
|
@ -906,3 +918,77 @@ class UserProfileTests(TestWithUser, WebTest):
|
||||||
profile_page = profile_page.follow()
|
profile_page = profile_page.follow()
|
||||||
self.assertEqual(profile_page.status_code, 200)
|
self.assertEqual(profile_page.status_code, 200)
|
||||||
self.assertContains(profile_page, "Your profile has been updated")
|
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"')
|
||||||
|
|
|
@ -383,7 +383,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
has_profile_flag = flag_is_active(self.request, "profile_feature")
|
has_profile_flag = flag_is_active(self.request, "profile_feature")
|
||||||
|
|
||||||
context_stuff = {}
|
context_stuff = {}
|
||||||
if DomainRequest._form_complete(self.domain_request):
|
if DomainRequest._form_complete(self.domain_request, self.request):
|
||||||
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
|
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
|
||||||
context_stuff = {
|
context_stuff = {
|
||||||
"not_form": False,
|
"not_form": False,
|
||||||
|
@ -695,7 +695,7 @@ class Review(DomainRequestWizard):
|
||||||
forms = [] # type: ignore
|
forms = [] # type: ignore
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
if DomainRequest._form_complete(self.domain_request) is False:
|
if DomainRequest._form_complete(self.domain_request, self.request) is False:
|
||||||
logger.warning("User arrived at review page with an incomplete form.")
|
logger.warning("User arrived at review page with an incomplete form.")
|
||||||
context = super().get_context_data()
|
context = super().get_context_data()
|
||||||
context["Step"] = Step.__members__
|
context["Step"] = Step.__members__
|
||||||
|
|
|
@ -9,6 +9,7 @@ def index(request):
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
# This is a django waffle flag which toggles features based off of the "flag" table
|
# 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_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
|
# This controls the creation of a new domain request in the wizard
|
||||||
request.session["new_request"] = True
|
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)
|
|
@ -2,13 +2,10 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import parse_qs, unquote
|
|
||||||
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.http import QueryDict
|
||||||
from django.views.generic.edit import FormMixin
|
from django.views.generic.edit import FormMixin
|
||||||
from registrar.forms.user_profile import UserProfileForm, FinishSetupProfileForm
|
from registrar.forms.user_profile import UserProfileForm, FinishSetupProfileForm
|
||||||
from django.urls import NoReverseMatch, reverse
|
from django.urls import NoReverseMatch, reverse
|
||||||
|
@ -20,9 +17,6 @@ from registrar.models.utility.generic_helper import replace_url_queryparams
|
||||||
from registrar.views.utility.permission_views import UserProfilePermissionView
|
from registrar.views.utility.permission_views import UserProfilePermissionView
|
||||||
from waffle.decorators import flag_is_active, waffle_flag
|
from waffle.decorators import flag_is_active, waffle_flag
|
||||||
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.views.decorators.csrf import csrf_protect
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,13 +28,18 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
||||||
model = Contact
|
model = Contact
|
||||||
template_name = "profile.html"
|
template_name = "profile.html"
|
||||||
form_class = UserProfileForm
|
form_class = UserProfileForm
|
||||||
|
base_view_name = "user-profile"
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""Handle get requests by getting user's contact object and setting object
|
"""Handle get requests by getting user's contact object and setting object
|
||||||
and form to context before rendering."""
|
and form to context before rendering."""
|
||||||
self._refresh_session_and_object(request)
|
self.object = self.get_object()
|
||||||
form = self.form_class(instance=self.object)
|
|
||||||
context = self.get_context_data(object=self.object, form=form)
|
# Get the redirect parameter from the query string
|
||||||
|
redirect = request.GET.get("redirect", "home")
|
||||||
|
|
||||||
|
form = self.form_class(instance=self.object, initial={"redirect": redirect})
|
||||||
|
context = self.get_context_data(object=self.object, form=form, redirect=redirect)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasattr(self.user, "finished_setup")
|
hasattr(self.user, "finished_setup")
|
||||||
|
@ -49,22 +48,10 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
||||||
):
|
):
|
||||||
context["show_confirmation_modal"] = True
|
context["show_confirmation_modal"] = True
|
||||||
|
|
||||||
return_to_request = request.GET.get("return_to_request")
|
|
||||||
if return_to_request:
|
|
||||||
context["return_to_request"] = True
|
|
||||||
|
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
def _refresh_session_and_object(self, request):
|
|
||||||
"""Sets the current session to self.session and the current object to self.object"""
|
|
||||||
self.session = request.session
|
|
||||||
self.object = self.get_object()
|
|
||||||
|
|
||||||
@waffle_flag("profile_feature") # type: ignore
|
@waffle_flag("profile_feature") # type: ignore
|
||||||
def dispatch(self, request, *args, **kwargs): # type: ignore
|
def dispatch(self, request, *args, **kwargs): # type: ignore
|
||||||
# Store the original queryparams to persist them
|
|
||||||
query_params = request.META["QUERY_STRING"]
|
|
||||||
request.session["query_params"] = query_params
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
@ -73,10 +60,14 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
||||||
# This is a django waffle flag which toggles features based off of the "flag" table
|
# This is a django waffle flag which toggles features based off of the "flag" table
|
||||||
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
|
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
|
||||||
|
|
||||||
# The text for the back button on this page
|
# Set the profile_back_button_text based on the redirect parameter
|
||||||
|
if kwargs.get("redirect") == "domain-request:":
|
||||||
|
context["profile_back_button_text"] = "Go back to your domain request"
|
||||||
|
else:
|
||||||
context["profile_back_button_text"] = "Go to manage your domains"
|
context["profile_back_button_text"] = "Go to manage your domains"
|
||||||
context["show_back_button"] = False
|
|
||||||
|
|
||||||
|
# Show back button conditional on user having finished setup
|
||||||
|
context["show_back_button"] = False
|
||||||
if hasattr(self.user, "finished_setup") and self.user.finished_setup:
|
if hasattr(self.user, "finished_setup") and self.user.finished_setup:
|
||||||
context["user_finished_setup"] = True
|
context["user_finished_setup"] = True
|
||||||
context["show_back_button"] = True
|
context["show_back_button"] = True
|
||||||
|
@ -84,21 +75,29 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
"""Redirect to the user's profile page."""
|
"""Redirect to the user's profile page with updated query parameters."""
|
||||||
|
|
||||||
query_params = {}
|
# Get the redirect parameter from the form submission
|
||||||
if "query_params" in self.session:
|
redirect_param = self.request.POST.get("redirect", None)
|
||||||
params = unquote(self.session["query_params"])
|
|
||||||
query_params = parse_qs(params)
|
|
||||||
|
|
||||||
# Preserve queryparams and add them back to the url
|
# Initialize QueryDict with existing query parameters from current request
|
||||||
base_url = reverse("user-profile")
|
query_params = QueryDict(mutable=True)
|
||||||
new_redirect = replace_url_queryparams(base_url, query_params, convert_list_to_csv=True)
|
query_params.update(self.request.GET)
|
||||||
return new_redirect
|
|
||||||
|
# Update query parameters with the 'redirect' value from form submission
|
||||||
|
if redirect_param and redirect_param != "home":
|
||||||
|
query_params["redirect"] = redirect_param
|
||||||
|
|
||||||
|
# Generate the URL with updated query parameters
|
||||||
|
base_url = reverse(self.base_view_name)
|
||||||
|
|
||||||
|
# Generate the full url from the given query params
|
||||||
|
full_url = replace_url_queryparams(base_url, query_params)
|
||||||
|
return full_url
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Handle post requests (form submissions)"""
|
"""Handle post requests (form submissions)"""
|
||||||
self._refresh_session_and_object(request)
|
self.object = self.get_object()
|
||||||
form = self.form_class(request.POST, instance=self.object)
|
form = self.form_class(request.POST, instance=self.object)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
@ -133,139 +132,53 @@ class FinishProfileSetupView(UserProfileView):
|
||||||
"""This view forces the user into providing additional details that
|
"""This view forces the user into providing additional details that
|
||||||
we may have missed from Login.gov"""
|
we may have missed from Login.gov"""
|
||||||
|
|
||||||
class RedirectType(Enum):
|
|
||||||
"""
|
|
||||||
Enums for each type of redirection. Enforces behaviour on `get_redirect_url()`.
|
|
||||||
|
|
||||||
- HOME: We want to redirect to reverse("home")
|
|
||||||
- BACK_TO_SELF: We want to redirect back to this page
|
|
||||||
- TO_SPECIFIC_PAGE: We want to redirect to the page specified in the queryparam "redirect"
|
|
||||||
- COMPLETE_SETUP: Indicates that we want to navigate BACK_TO_SELF, but the subsequent
|
|
||||||
redirect after the next POST should be either HOME or TO_SPECIFIC_PAGE
|
|
||||||
"""
|
|
||||||
|
|
||||||
HOME = "home"
|
|
||||||
TO_SPECIFIC_PAGE = "domain_request"
|
|
||||||
BACK_TO_SELF = "back_to_self"
|
|
||||||
COMPLETE_SETUP = "complete_setup"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_all_redirect_types(cls) -> list[str]:
|
|
||||||
"""Returns the value of every redirect type defined in this enum."""
|
|
||||||
return [r.value for r in cls]
|
|
||||||
|
|
||||||
template_name = "finish_profile_setup.html"
|
template_name = "finish_profile_setup.html"
|
||||||
form_class = FinishSetupProfileForm
|
form_class = FinishSetupProfileForm
|
||||||
model = Contact
|
model = Contact
|
||||||
|
|
||||||
all_redirect_types = RedirectType.get_all_redirect_types()
|
base_view_name = "finish-user-profile-setup"
|
||||||
redirect_type: RedirectType
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Extend get_context_data to include has_profile_feature_flag"""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
# Hide the back button by default
|
# Show back button conditional on user having finished setup
|
||||||
context["show_back_button"] = False
|
context["show_back_button"] = False
|
||||||
|
if hasattr(self.user, "finished_setup") and self.user.finished_setup:
|
||||||
if self.redirect_type == self.RedirectType.COMPLETE_SETUP:
|
if kwargs.get("redirect") == "home":
|
||||||
context["confirm_changes"] = True
|
|
||||||
|
|
||||||
if "redirect_viewname" not in self.session:
|
|
||||||
context["show_back_button"] = True
|
context["show_back_button"] = True
|
||||||
else:
|
else:
|
||||||
context["going_to_specific_page"] = True
|
context["going_to_specific_page"] = True
|
||||||
context["redirect_button_text"] = "Continue to your request"
|
context["redirect_button_text"] = "Continue to your request"
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
@method_decorator(csrf_protect)
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Handles dispatching of the view, applying CSRF protection and checking the 'profile_feature' flag.
|
|
||||||
|
|
||||||
This method sets the redirect type based on the 'redirect' query parameter,
|
|
||||||
defaulting to BACK_TO_SELF if not provided.
|
|
||||||
It updates the session with the redirect view name if the redirect type is TO_SPECIFIC_PAGE.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HttpResponse: The response generated by the parent class's dispatch method.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Update redirect type based on the query parameter if present
|
|
||||||
default_redirect_value = self.RedirectType.BACK_TO_SELF.value
|
|
||||||
redirect_value = request.GET.get("redirect", default_redirect_value)
|
|
||||||
|
|
||||||
if redirect_value in self.all_redirect_types:
|
|
||||||
# If the redirect value is a preexisting value in our enum, set it to that.
|
|
||||||
self.redirect_type = self.RedirectType(redirect_value)
|
|
||||||
else:
|
|
||||||
# If the redirect type is undefined, then we assume that we are specifying a particular page to redirect to.
|
|
||||||
self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE
|
|
||||||
|
|
||||||
# Store the page that we want to redirect to for later use
|
|
||||||
request.session["redirect_viewname"] = str(redirect_value)
|
|
||||||
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Form submission posts to this view."""
|
"""Form submission posts to this view."""
|
||||||
self._refresh_session_and_object(request)
|
self.object = self.get_object()
|
||||||
form = self.form_class(request.POST, instance=self.object)
|
form = self.form_class(request.POST, instance=self.object)
|
||||||
|
|
||||||
# Get the current form and validate it
|
# Get the current form and validate it
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
self.redirect_page = False
|
||||||
if "contact_setup_save_button" in request.POST:
|
if "contact_setup_save_button" in request.POST:
|
||||||
# Logic for when the 'Save' button is clicked
|
# Logic for when the 'Save' button is clicked, which indicates
|
||||||
self.redirect_type = self.RedirectType.COMPLETE_SETUP
|
# user should stay on this page
|
||||||
|
self.redirect_page = False
|
||||||
elif "contact_setup_submit_button" in request.POST:
|
elif "contact_setup_submit_button" in request.POST:
|
||||||
specific_redirect = "redirect_viewname" in self.session
|
# Logic for when the other button is clicked, which indicates
|
||||||
self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE if specific_redirect else self.RedirectType.HOME
|
# the user should be taken to the redirect page
|
||||||
|
self.redirect_page = True
|
||||||
return self.form_valid(form)
|
return self.form_valid(form)
|
||||||
else:
|
else:
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
"""Redirect to the nameservers page for the domain."""
|
"""Redirect to the redirect page, or redirect to the current page"""
|
||||||
return self.get_redirect_url()
|
|
||||||
|
|
||||||
def get_redirect_url(self):
|
|
||||||
"""
|
|
||||||
Returns a URL string based on the current value of self.redirect_type.
|
|
||||||
|
|
||||||
Depending on self.redirect_type, constructs a base URL and appends a
|
|
||||||
'redirect' query parameter. Handles different redirection types such as
|
|
||||||
HOME, BACK_TO_SELF, COMPLETE_SETUP, and TO_SPECIFIC_PAGE.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The full URL with the appropriate query parameters.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# These redirect types redirect to the same page
|
|
||||||
self_redirect = [self.RedirectType.BACK_TO_SELF, self.RedirectType.COMPLETE_SETUP]
|
|
||||||
|
|
||||||
# Maps the redirect type to a URL
|
|
||||||
base_url = ""
|
|
||||||
try:
|
try:
|
||||||
if self.redirect_type in self_redirect:
|
# Get the redirect parameter from the form submission
|
||||||
base_url = reverse("finish-user-profile-setup")
|
redirect_param = self.request.POST.get("redirect", None)
|
||||||
elif self.redirect_type == self.RedirectType.TO_SPECIFIC_PAGE:
|
if self.redirect_page and redirect_param:
|
||||||
# We only allow this session value to use viewnames,
|
return reverse(redirect_param)
|
||||||
# because this restricts what can be redirected to.
|
|
||||||
desired_view = self.session["redirect_viewname"]
|
|
||||||
self.session.pop("redirect_viewname")
|
|
||||||
base_url = reverse(desired_view)
|
|
||||||
else:
|
|
||||||
base_url = reverse("home")
|
|
||||||
except NoReverseMatch as err:
|
except NoReverseMatch as err:
|
||||||
logger.error(f"get_redirect_url -> Could not find the specified page. Err: {err}")
|
logger.error(f"get_redirect_url -> Could not find the specified page. Err: {err}")
|
||||||
|
return super().get_success_url()
|
||||||
query_params = {}
|
|
||||||
|
|
||||||
# Quote cleans up the value so that it can be used in a url
|
|
||||||
if self.redirect_type and self.redirect_type.value:
|
|
||||||
query_params["redirect"] = quote(self.redirect_type.value)
|
|
||||||
|
|
||||||
# Generate the full url from the given query params
|
|
||||||
full_url = replace_url_queryparams(base_url, query_params)
|
|
||||||
return full_url
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue