merged main

This commit is contained in:
David Kennedy 2024-04-12 11:57:50 -04:00
commit 5761a8a16e
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
27 changed files with 1247 additions and 72 deletions

View file

@ -0,0 +1,61 @@
---
name: Designer Onboarding
about: Onboarding steps for designers.
title: 'Designer Onboarding: GH_HANDLE'
labels: design, onboarding
assignees: katherineosos
---
# Designer Onboarding
- Onboardee: _GH handle of person being onboarded_
- Onboarder: _GH handle of onboard buddy_
Welcome to the .gov team! We're excited to have you here. Please follow the steps below to get everything set up. An onboarding buddy will help grant you access to all the tools and platforms we use. If you haven't been assigned an onboarding buddy, let us know in the #dotgov-disco channel.
## Onboardee
### Steps for the onboardee
- [ ] Read the [.gov onboarding doc](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?usp=sharing) thoroughly.
- [ ] Accept an invitation to our Slack workspace and fill out your profile.
- [ ] For our Slack profile names, we usually follow the naming convention of `Firstname Lastname (Org, State, pronouns)`.
Example: Katherine Osos (Truss, MN, she/her)
- [ ] Make sure you have been added to the necessary [channels](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.li3lqcygw8ax) and familiarize yourself with them.
- [ ] FOR FEDERAL EMPLOYEES: Create a [Google workspace enterprise account](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.xowzg9w0qlis) for CISA.
- [ ] Get access to our [Project Folder](https://drive.google.com/drive/folders/1qkoFQBlzXA7axi9CZ_OBhlJqRcqlNfpW?usp=drive_link) on Google Drive.
- [ ] Explore the folders and docs. Designers interface with the Product Design, Content, and Research folders most often.
- [ ] Make sure you have been invited to our [team meetings](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.emgtp2hgvabr) on Google Meet.
- [ ] Review our [design tools](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.aprurp3z4gmv).
- [ ] Accept invitation to our [Figma workspace](https://www.figma.com/files/1287135731043703282/team/1299882813146449644).
- [ ] Follow the steps in [preparing for your sandbox](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.au66hq5e0l8s) section on the onboarding doc.
- [ ] Schedule coffee chats with Design Lead, other designers, scrum master, and product manager ([team directory](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.1vq6r8e52e9f)).
- [ ] Look over [recommended reading](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.7ox9ee7v5q5n) and [relevant links](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.d9pac1gc751t).
- [ ] Fill out your own Personal Operating Manual (POM) on the [team norming board](https://miro.com/app/board/uXjVMxMu1SA=/). OPTIONAL: Present it on the next team coffee meeting.
- [ ] FOR FEDERAL EMPLOYEES: Check in with your manager on the CISA onboarding process and getting your PIV card.
- [ ] FOR CONTRACTORS: Check with your manager on your EOD clearance process.
- [ ] OPTIONAL: Request access to our [analytics tools](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.9q334hs4lbks).
- [ ] OPTIONAL: If you would like to manage content updates by running code locally rather than through GitHub, follow the steps in the [dev onboarding ticket](https://github.com/cisagov/getgov/issues/new?assignees=loganmeetsworld&labels=dev%2C+onboarding&template=developer-onboarding.md&title=Developer+Onboarding%3A+GH_HANDLE).
- [ ] If you would like to run code locally on a CISA laptop, reference the "[Setting up a developer environment on CISA laptops](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.2ctyba51d1zp)" section of the onboarding doc.
### Access
By following the steps, you should have access / been added to the following:
- [ ] The [.gov team](https://github.com/orgs/cisagov/teams/gov) under cisagov on GitHub
- [ ] [Slack](https://dhscisa.enterprise.slack.com), and have been added to the necessary channels
- [ ] [Google Drive Project folder](https://drive.google.com/drive/folders/1qkoFQBlzXA7axi9CZ_OBhlJqRcqlNfpW?usp=drive_link)
- [ ] [.gov team on Figma](https://www.figma.com/files/1287135731043703282/team/1299882813146449644) (as an editor if you have a license)
- [ ] [Team meetings](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.h62kzew057p1)
## Onboarder
### Steps for the onboarder
- [ ] Make sure onboardee is given an invitation to our Slack workspace
- [ ] Add onboardee to dotgov Slack channels
- [ ] Add onboardee to Project Folder as a "Contributor" (or if you do not have permissions, confirm with Cameron)
- If onboardee if a federal employee, add them as a "Content Manager" instead
- [ ] Once onboardee has been granted a Figma license, add them as an editor to the Figma team workspace, Otherwise, you can add them as a viewer in the meantime.
- [ ] Add onboardee to our team meetings
- [ ] If applicable, invite onboardee to Google Analytics, Google Search Console, and Search.gov console

View file

@ -4,7 +4,7 @@ The .gov domain helps U.S.-based government organizations gain public trust by b
## Onboarding
For new members of the @cisagov/dotgov team looking to contribute to the registrar, please open an [onboarding ticket](https://github.com/cisagov/getgov/issues/new?assignees=loganmeetsworld&labels=dev%2C+onboarding&template=developer-onboarding.md&title=Developer+Onboarding%3A+GH_HANDLE).
For new members of the @cisagov/dotgov team looking to contribute to the registrar, please open a [developer onboarding ticket](https://github.com/cisagov/getgov/issues/new?assignees=loganmeetsworld&labels=dev%2C+onboarding&template=developer-onboarding.md&title=Developer+Onboarding%3A+GH_HANDLE) or a [designer onboarding ticket](https://github.com/cisagov/getgov/issues/new?assignees=loganmeetsworld&labels=design%2C+onboarding&template=designer-onboarding.md&title=Designer+Onboarding%3A+GH_HANDLE).
## Code

View file

@ -73,14 +73,23 @@ cp ./.env-example .env
Get the secrets from Cloud.gov by running `cf env getgov-YOURSANDBOX`. More information is available in [rotate_application_secrets.md](../operations/runbooks/rotate_application_secrets.md).
## Adding user to /admin
## Getting access to /admin on all development sandboxes (also referred to as "adding to fixtures")
The endpoint /admin can be used to view and manage site content, including but not limited to user information and the list of current applications in the database. To be able to view and use /admin locally:
The endpoint /admin can be used to view and manage site content, including but not limited to user information and the list of current applications in the database. However, access to this is limited to analysts and full-access users with regular domain requestors and domain managers not being able to see this page.
While on production (the sandbox referred to as `stable`), an existing analyst or full-access user typically grants access /admin as part of onboarding ([see these instructions](../django-admin/roles.md)), doing this for all development sandboxes is very time consuming. Instead, to get access to /admin on all development sandboxes and when developing code locally, refer to the following sections depending on what level of user access you desire.
### Adding full-access user to /admin
To get access to /admin on every non-production sandbox and to use /admin in local development, do the following:
1. Login via login.gov
2. Go to the home page and make sure you can see the part where you can submit a domain request
3. Go to /admin and it will tell you that UUID is not authorized, copy that UUID for use in 4
4. in src/registrar/fixtures_users.py add to the `ADMINS` list in that file by adding your UUID as your username along with your first and last name. See below:
3. Go to /admin and it will tell you that your UUID is not authorized (it shows a very long string, this is your UUID). Copy that UUID for use in 4.
4. (Designers) Message in #getgov-dev that you need access to admin as a `superuser` and send them this UUID along with your desired email address. Please see the "Adding an Analyst to /admin" section below to complete similiar steps if you also desire an `analyst` user account. Engineers will handle the remaining steps for designers, stop here.
(Engineers) In src/registrar/fixtures_users.py add to the `ADMINS` list in that file by adding your UUID as your username along with your first and last name. See below:
```
ADMINS = [
@ -93,16 +102,18 @@ The endpoint /admin can be used to view and manage site content, including but n
]
```
5. In the browser, navigate to /admin. To verify that all is working correctly, under "domain requests" you should see fake domains with various fake statuses.
6. Add an optional email key/value pair
5. (Engineers) In the browser, navigate to /admin. To verify that all is working correctly, under "domain requests" you should see fake domains with various fake statuses.
6. (Engineers) Add an optional email key/value pair
### Adding an Analyst to /admin
### Adding an analyst-level user to /admin
Analysts are a variant of the admin role with limited permissions. The process for adding an Analyst is much the same as adding an admin:
1. Login via login.gov (if you already exist as an admin, you will need to create a separate login.gov account for this: i.e. first.last+1@email.com)
2. Go to the home page and make sure you can see the part where you can submit a domain request
3. Go to /admin and it will tell you that UUID is not authorized, copy that UUID for use in 4 (this will be a different UUID than the one obtained from creating an admin)
4. in src/registrar/fixtures_users.py add to the `STAFF` list in that file by adding your UUID as your username along with your first and last name. See below:
4. (Designers) Message in #getgov-dev that you need access to admin as a `superuser` and send them this UUID along with your desired email address. Engineers will handle the remaining steps for designers, stop here.
5. (Engineers) In src/registrar/fixtures_users.py add to the `STAFF` list in that file by adding your UUID as your username along with your first and last name. See below:
```
STAFF = [
@ -115,10 +126,11 @@ Analysts are a variant of the admin role with limited permissions. The process f
]
```
5. In the browser, navigate to /admin. To verify that all is working correctly, verify that you can only see a sub-section of the modules and some are set to view-only.
6. Add an optional email key/value pair
5. (Engineers) In the browser, navigate to /admin. To verify that all is working correctly, verify that you can only see a sub-section of the modules and some are set to view-only.
6. (Engineers) Add an optional email key/value pair
Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst`
## Adding to CODEOWNERS (optional)
The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of.

View file

@ -19,6 +19,18 @@ role or set of permissions that they have. We use a `UserDomainRole`
`User.domains` many-to-many relationship that works through the
`UserDomainRole` link table.
## Migrating changes to Analyst Permissions model
Analysts are allowed a certain set of read/write registrar permissions.
Setting user permissions requires a migration to change the UserGroup
and Permission models, which requires us to manually make a migration
file for user permission changes.
To update analyst permissions do the following:
1. Make desired changes to analyst group permissions in user_group.py.
2. Follow the steps in the migration file0037_create_groups_v01.py to
create a duplicate migration for the updated user group permissions.
3. To migrate locally, run docker-compose up. To migrate on a sandbox,
push the new migration onto your sandbox before migrating.
## Permission decorator
The Django objects that need to be permission controlled are various views.

View file

@ -9,7 +9,7 @@ our `user_group` model and run in a migration.
For more details, refer to the [user group model](../../src/registrar/models/user_group.py).
## Adding a user as analyst or granting full access via django-admin
## Adding a user as analyst or granting full access via django-admin (/admin)
If a new team member has joined, then they will need to be granted analyst (`cisa_analysts_group`) or full access (`full_access_group`) permissions in order to view the admin pages. These admin pages are the ones found at manage.get.gov/admin.
To do this, do the following:
@ -21,7 +21,7 @@ To do this, do the following:
5. (Optional) If the user needs access to django admin (such as an analyst), then you will also need to make sure "Staff Status" is checked. This can be found in the same `User Permissions` section right below the checkbox for `Active`.
6. Click `Save` to apply all changes.
## Removing a user group permission via django-admin
## Removing a user group permission via django-admin (/admin)
If an employee was given the wrong permissions or has had a change in roles that subsequently requires a permission change, then their permissions should be updated in django-admin. Much like in the previous section you can accomplish this by doing the following:

View file

@ -779,6 +779,46 @@ class WebsiteAdmin(ListHeaderAdmin):
]
search_help_text = "Search by website."
def get_model_perms(self, request):
"""
Return empty perms dict thus hiding the model from admin index.
"""
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
if analyst_perm and not superuser_perm:
return {}
return super().get_model_perms(request)
def has_change_permission(self, request, obj=None):
"""
Allow analysts to access the change form directly via URL.
"""
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
if analyst_perm and not superuser_perm:
return True
return super().has_change_permission(request, obj)
def response_change(self, request, obj):
"""
Override to redirect users back to the previous page after saving.
"""
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
return_path = request.GET.get("return_path")
# First, call the super method to perform the standard operations and capture the response
response = super().response_change(request, obj)
# Don't redirect to the website page on save if the user is an analyst.
# Rather, just redirect back to the originating page.
if (analyst_perm and not superuser_perm) and return_path:
# Redirect to the return path if it exists
return HttpResponseRedirect(return_path)
# If no redirection is needed, return the original response
return response
class UserDomainRoleAdmin(ListHeaderAdmin):
"""Custom user domain role admin class."""
@ -901,6 +941,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
"fields": [
"generic_org_type",
"is_election_board",
"organization_type",
]
},
),
@ -943,7 +984,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
]
# Readonly fields for analysts and superusers
readonly_fields = ("other_contacts",)
readonly_fields = ("other_contacts", "generic_org_type", "is_election_board")
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
@ -1139,6 +1180,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
"fields": [
"generic_org_type",
"is_election_board",
"organization_type",
]
},
),
@ -1181,7 +1223,13 @@ class DomainRequestAdmin(ListHeaderAdmin):
]
# Readonly fields for analysts and superusers
readonly_fields = ("other_contacts", "current_websites", "alternative_domains")
readonly_fields = (
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
)
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
@ -1458,7 +1506,10 @@ class DomainInformationInline(admin.StackedInline):
def has_change_permission(self, request, obj=None):
"""Custom has_change_permission override so that we can specify that
analysts can edit this through this inline, but not through the model normally"""
if request.user.has_perm("registrar.analyst_access_permission"):
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
if analyst_perm and not superuser_perm:
return True
return super().has_change_permission(request, obj)
@ -1619,11 +1670,7 @@ class DomainAdmin(ListHeaderAdmin):
# No expiration date was found. Return none.
extra_context["extended_expiration_date"] = None
return super().changeform_view(request, object_id, form_url, extra_context)
if curr_exp_date < date.today():
extra_context["extended_expiration_date"] = date.today() + relativedelta(years=years_to_extend_by)
else:
new_date = domain.registry_expiration_date + relativedelta(years=years_to_extend_by)
new_date = curr_exp_date + relativedelta(years=years_to_extend_by)
extra_context["extended_expiration_date"] = new_date
else:
extra_context["extended_expiration_date"] = None
@ -1888,6 +1935,46 @@ class DraftDomainAdmin(ListHeaderAdmin):
# in autocomplete_fields for user
ordering = ["name"]
def get_model_perms(self, request):
"""
Return empty perms dict thus hiding the model from admin index.
"""
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
if analyst_perm and not superuser_perm:
return {}
return super().get_model_perms(request)
def has_change_permission(self, request, obj=None):
"""
Allow analysts to access the change form directly via URL.
"""
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
if analyst_perm and not superuser_perm:
return True
return super().has_change_permission(request, obj)
def response_change(self, request, obj):
"""
Override to redirect users back to the previous page after saving.
"""
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
return_path = request.GET.get("return_path")
# First, call the super method to perform the standard operations and capture the response
response = super().response_change(request, obj)
# Don't redirect to the website page on save if the user is an analyst.
# Rather, just redirect back to the originating page.
if (analyst_perm and not superuser_perm) and return_path:
# Redirect to the return path if it exists
return HttpResponseRedirect(return_path)
# If no redirection is needed, return the original response
return response
class PublicContactAdmin(ListHeaderAdmin):
"""Custom PublicContact admin class."""

View file

@ -98,6 +98,8 @@ class DomainRequestFixture:
def _set_non_foreign_key_fields(cls, da: DomainRequest, app: dict):
"""Helper method used by `load`."""
da.status = app["status"] if "status" in app else "started"
# TODO for a future ticket: Allow for more than just "federal" here
da.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal"
da.federal_agency = (
app["federal_agency"]
@ -235,9 +237,6 @@ class DomainFixture(DomainRequestFixture):
).last()
logger.debug(f"Approving {domain_request} for {user}")
# We don't want fixtures sending out real emails to
# fake email addresses, so we just skip that and log it instead
# All approvals require an investigator, so if there is none,
# assign one.
if domain_request.investigator is None:

View file

@ -2,7 +2,7 @@
import logging
from django import forms
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
from django.forms import formset_factory
from registrar.models import DomainRequest
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -31,7 +31,17 @@ logger = logging.getLogger(__name__)
class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain."""
email = forms.EmailField(label="Email")
email = forms.EmailField(
label="Email",
max_length=None,
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
def clean(self):
"""clean form data by lowercasing email"""
@ -171,6 +181,8 @@ NameserverFormset = formset_factory(
class ContactForm(forms.ModelForm):
"""Form for updating contacts."""
email = forms.EmailField(max_length=None)
class Meta:
model = Contact
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
@ -194,6 +206,10 @@ class ContactForm(forms.ModelForm):
# which interferes with out input_with_errors template tag
self.fields["phone"].widget.attrs.pop("maxlength", None)
# Define a custom validator for the email field with a custom error message
email_max_length_validator = MaxLengthValidator(320, message="Response must be less than 320 characters.")
self.fields["email"].validators.append(email_max_length_validator)
for field_name in self.required:
self.fields[field_name].required = True
@ -291,10 +307,17 @@ class DomainSecurityEmailForm(forms.Form):
security_email = forms.EmailField(
label="Security email (optional)",
max_length=None,
required=False,
error_messages={
"invalid": str(SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)),
},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)

View file

@ -369,7 +369,14 @@ class AuthorizingOfficialForm(RegistrarForm):
)
email = forms.EmailField(
label="Email",
max_length=None,
error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
@ -566,7 +573,14 @@ class YourContactForm(RegistrarForm):
)
email = forms.EmailField(
label="Email",
max_length=None,
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
phone = PhoneNumberField(
label="Phone",
@ -621,10 +635,17 @@ class OtherContactsForm(RegistrarForm):
)
email = forms.EmailField(
label="Email",
max_length=None,
error_messages={
"required": ("Enter an email address in the required format, like name@example.com."),
"invalid": ("Enter an email address in the required format, like name@example.com."),
},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
phone = PhoneNumberField(
label="Phone",

View file

@ -0,0 +1,83 @@
# Generated by Django 4.2.10 on 2024-04-01 15:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0081_create_groups_v10"),
]
operations = [
migrations.AddField(
model_name="domaininformation",
name="organization_type",
field=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"),
("state_or_territory_election", "State or territory - Election"),
("tribal_election", "Tribal - Election"),
("county_election", "County - Election"),
("city_election", "City - Election"),
("special_district_election", "Special district - Election"),
],
help_text="Type of organization - Election office",
max_length=255,
null=True,
),
),
migrations.AddField(
model_name="domainrequest",
name="organization_type",
field=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"),
("state_or_territory_election", "State or territory - Election"),
("tribal_election", "Tribal - Election"),
("county_election", "County - Election"),
("city_election", "City - Election"),
("special_district_election", "Special district - Election"),
],
help_text="Type of organization - Election office",
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="domaininformation",
name="generic_org_type",
field=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,
),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 4.2.10 on 2024-04-09 16:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0082_domaininformation_organization_type_and_more"),
]
operations = [
migrations.AlterField(
model_name="contact",
name="email",
field=models.EmailField(blank=True, db_index=True, max_length=320, null=True),
),
migrations.AlterField(
model_name="publiccontact",
name="email",
field=models.EmailField(help_text="Contact's email address", max_length=320),
),
]

View 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", "0083_alter_contact_email_alter_publiccontact_email"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -40,6 +40,7 @@ class Contact(TimeStampedModel):
null=True,
blank=True,
db_index=True,
max_length=320,
)
phone = PhoneNumberField(
null=True,

View file

@ -198,7 +198,6 @@ class Domain(TimeStampedModel, DomainHelper):
is called in the validate function on the request/domain page
throws- RegistryError or InvalidDomainError"""
if not cls.string_could_be_domain(domain):
logger.warning("Not a valid domain: %s" % str(domain))
# throw invalid domain error so that it can be caught in

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from django.db import transaction
from registrar.models.utility.domain_helper import DomainHelper
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from .domain_request import DomainRequest
from .utility.time_stamped_model import TimeStampedModel
@ -54,7 +55,23 @@ class DomainInformation(TimeStampedModel):
choices=OrganizationChoices.choices,
null=True,
blank=True,
help_text="Type of Organization",
help_text="Type of organization",
)
# TODO - Ticket #1911: stub this data from DomainRequest
is_election_board = models.BooleanField(
null=True,
blank=True,
help_text="Is your organization an election office?",
)
# TODO - Ticket #1911: stub this data from DomainRequest
organization_type = models.CharField(
max_length=255,
choices=DomainRequest.OrgChoicesElectionOffice.choices,
null=True,
blank=True,
help_text="Type of organization - Election office",
)
federally_recognized_tribe = models.BooleanField(
@ -219,6 +236,34 @@ class DomainInformation(TimeStampedModel):
except Exception:
return ""
def save(self, *args, **kwargs):
"""Save override for custom properties"""
# Define mappings between generic org and election org.
# These have to be defined here, as you'd get a cyclical import error
# otherwise.
# For any given organization type, return the "_election" variant.
# For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION
generic_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_generic_to_org_election()
# For any given "_election" variant, return the base org type.
# For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY
election_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_election_to_org_generic()
# Manages the "organization_type" variable and keeps in sync with
# "is_election_office" and "generic_organization_type"
org_type_helper = CreateOrUpdateOrganizationTypeHelper(
sender=self.__class__,
instance=self,
generic_org_to_org_map=generic_org_map,
election_org_to_generic_org_map=election_org_map,
)
# Actually updates the organization_type field
org_type_helper.create_or_update_organization_type()
super().save(*args, **kwargs)
@classmethod
def create_from_da(cls, domain_request: DomainRequest, domain=None):
"""Takes in a DomainRequest and converts it into DomainInformation"""

View file

@ -9,6 +9,7 @@ from django.db import models
from django_fsm import FSMField, transition # type: ignore
from django.utils import timezone
from registrar.models.domain import Domain
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from .utility.time_stamped_model import TimeStampedModel
@ -100,8 +101,8 @@ class DomainRequest(TimeStampedModel):
class OrganizationChoices(models.TextChoices):
"""
Primary organization choices:
For use in django admin
Keys need to match OrganizationChoicesVerbose
For use in the domain request experience
Keys need to match OrgChoicesElectionOffice and OrganizationChoicesVerbose
"""
FEDERAL = "federal", "Federal"
@ -113,9 +114,77 @@ class DomainRequest(TimeStampedModel):
SPECIAL_DISTRICT = "special_district", "Special district"
SCHOOL_DISTRICT = "school_district", "School district"
class OrgChoicesElectionOffice(models.TextChoices):
"""
Primary organization choices for Django admin:
Keys need to match OrganizationChoices and OrganizationChoicesVerbose.
The enums here come in two variants:
Regular (matches the choices from OrganizationChoices)
Election (Appends " - Election" to the string)
When adding the election variant, you must append "_election" to the end of the string.
"""
# We can't inherit OrganizationChoices due to models.TextChoices being an enum.
# We can redefine these values instead.
FEDERAL = "federal", "Federal"
INTERSTATE = "interstate", "Interstate"
STATE_OR_TERRITORY = "state_or_territory", "State or territory"
TRIBAL = "tribal", "Tribal"
COUNTY = "county", "County"
CITY = "city", "City"
SPECIAL_DISTRICT = "special_district", "Special district"
SCHOOL_DISTRICT = "school_district", "School district"
# Election variants
STATE_OR_TERRITORY_ELECTION = "state_or_territory_election", "State or territory - Election"
TRIBAL_ELECTION = "tribal_election", "Tribal - Election"
COUNTY_ELECTION = "county_election", "County - Election"
CITY_ELECTION = "city_election", "City - Election"
SPECIAL_DISTRICT_ELECTION = "special_district_election", "Special district - Election"
@classmethod
def get_org_election_to_org_generic(cls):
"""
Creates and returns a dictionary mapping from election-specific organization
choice enums to their corresponding general organization choice enums.
If no such mapping exists, it is simple excluded from the map.
"""
# This can be mapped automatically but its harder to read.
# For clarity reasons, we manually define this.
org_election_map = {
cls.STATE_OR_TERRITORY_ELECTION: cls.STATE_OR_TERRITORY,
cls.TRIBAL_ELECTION: cls.TRIBAL,
cls.COUNTY_ELECTION: cls.COUNTY,
cls.CITY_ELECTION: cls.CITY,
cls.SPECIAL_DISTRICT_ELECTION: cls.SPECIAL_DISTRICT,
}
return org_election_map
@classmethod
def get_org_generic_to_org_election(cls):
"""
Creates and returns a dictionary mapping from general organization
choice enums to their corresponding election-specific organization enums.
If no such mapping exists, it is simple excluded from the map.
"""
# This can be mapped automatically but its harder to read.
# For clarity reasons, we manually define this.
org_election_map = {
cls.STATE_OR_TERRITORY: cls.STATE_OR_TERRITORY_ELECTION,
cls.TRIBAL: cls.TRIBAL_ELECTION,
cls.COUNTY: cls.COUNTY_ELECTION,
cls.CITY: cls.CITY_ELECTION,
cls.SPECIAL_DISTRICT: cls.SPECIAL_DISTRICT_ELECTION,
}
return org_election_map
class OrganizationChoicesVerbose(models.TextChoices):
"""
Secondary organization choices
Tertiary organization choices
For use in the domain request form and on the templates
Keys need to match OrganizationChoices
"""
@ -406,6 +475,21 @@ class DomainRequest(TimeStampedModel):
help_text="Type of organization",
)
is_election_board = models.BooleanField(
null=True,
blank=True,
help_text="Is your organization an election office?",
)
# TODO - Ticket #1911: stub this data from DomainRequest
organization_type = models.CharField(
max_length=255,
choices=OrgChoicesElectionOffice.choices,
null=True,
blank=True,
help_text="Type of organization - Election office",
)
federally_recognized_tribe = models.BooleanField(
null=True,
help_text="Is the tribe federally recognized",
@ -437,18 +521,13 @@ class DomainRequest(TimeStampedModel):
help_text="Federal government branch",
)
is_election_board = models.BooleanField(
null=True,
blank=True,
help_text="Is your organization an election office?",
)
organization_name = models.CharField(
null=True,
blank=True,
help_text="Organization name",
db_index=True,
)
address_line1 = models.CharField(
null=True,
blank=True,
@ -525,6 +604,7 @@ class DomainRequest(TimeStampedModel):
related_name="domain_request",
on_delete=models.PROTECT,
)
alternative_domains = models.ManyToManyField(
"registrar.Website",
blank=True,
@ -586,6 +666,34 @@ class DomainRequest(TimeStampedModel):
help_text="Notes about this request",
)
def save(self, *args, **kwargs):
"""Save override for custom properties"""
# Define mappings between generic org and election org.
# These have to be defined here, as you'd get a cyclical import error
# otherwise.
# For any given organization type, return the "_election" variant.
# For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION
generic_org_map = self.OrgChoicesElectionOffice.get_org_generic_to_org_election()
# For any given "_election" variant, return the base org type.
# For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY
election_org_map = self.OrgChoicesElectionOffice.get_org_election_to_org_generic()
# Manages the "organization_type" variable and keeps in sync with
# "is_election_office" and "generic_organization_type"
org_type_helper = CreateOrUpdateOrganizationTypeHelper(
sender=self.__class__,
instance=self,
generic_org_to_org_map=generic_org_map,
election_org_to_generic_org_map=election_org_map,
)
# Actually updates the organization_type field
org_type_helper.create_or_update_organization_type()
super().save(*args, **kwargs)
def __str__(self):
try:
if self.requested_domain and self.requested_domain.name:

View file

@ -68,7 +68,7 @@ class PublicContact(TimeStampedModel):
sp = models.CharField(null=False, help_text="Contact's state or province")
pc = models.CharField(null=False, help_text="Contact's postal code")
cc = models.CharField(null=False, help_text="Contact's country code")
email = models.EmailField(null=False, help_text="Contact's email address")
email = models.EmailField(null=False, help_text="Contact's email address", max_length=320)
voice = models.CharField(null=False, help_text="Contact's phone number. Must be in ITU.E164.2005 format")
fax = models.CharField(
null=True,

View file

@ -5,6 +5,11 @@ logger = logging.getLogger(__name__)
class UserGroup(Group):
"""
UserGroup sets read and write permissions for superusers (who have full access)
and analysts. For more details, see the dev docs for user-permissions.
"""
class Meta:
verbose_name = "User group"
verbose_name_plural = "User groups"
@ -36,11 +41,6 @@ class UserGroup(Group):
"model": "domain",
"permissions": ["view_domain"],
},
{
"app_label": "registrar",
"model": "draftdomain",
"permissions": ["change_draftdomain"],
},
{
"app_label": "registrar",
"model": "user",
@ -51,11 +51,6 @@ class UserGroup(Group):
"model": "domaininvitation",
"permissions": ["add_domaininvitation", "view_domaininvitation"],
},
{
"app_label": "registrar",
"model": "website",
"permissions": ["change_website"],
},
{
"app_label": "registrar",
"model": "userdomainrole",

View file

@ -35,3 +35,219 @@ class Timer:
self.end = time.time()
self.duration = self.end - self.start
logger.info(f"Execution time: {self.duration} seconds")
class CreateOrUpdateOrganizationTypeHelper:
"""
A helper that manages the "organization_type" field in DomainRequest and DomainInformation
"""
def __init__(self, sender, instance, generic_org_to_org_map, election_org_to_generic_org_map):
# The "model type"
self.sender = sender
self.instance = instance
self.generic_org_to_org_map = generic_org_to_org_map
self.election_org_to_generic_org_map = election_org_to_generic_org_map
def create_or_update_organization_type(self):
"""The organization_type field on DomainRequest and DomainInformation is consituted from the
generic_org_type and is_election_board fields. To keep the organization_type
field up to date, we need to update it before save based off of those field
values.
If the instance is marked as an election board and the generic_org_type is not
one of the excluded types (FEDERAL, INTERSTATE, SCHOOL_DISTRICT), the
organization_type is set to a corresponding election variant. Otherwise, it directly
mirrors the generic_org_type value.
"""
# A new record is added with organization_type not defined.
# This happens from the regular domain request flow.
is_new_instance = self.instance.id is None
if is_new_instance:
self._handle_new_instance()
else:
self._handle_existing_instance()
return self.instance
def _handle_new_instance(self):
# == Check for invalid conditions before proceeding == #
should_proceed = self._validate_new_instance()
if not should_proceed:
return None
# == Program flow will halt here if there is no reason to update == #
# == Update the linked values == #
organization_type_needs_update = self.instance.organization_type is None
generic_org_type_needs_update = self.instance.generic_org_type is None
# If a field is none, it indicates (per prior checks) that the
# related field (generic org type <-> org type) has data and we should update according to that.
if organization_type_needs_update:
self._update_org_type_from_generic_org_and_election()
elif generic_org_type_needs_update:
self._update_generic_org_and_election_from_org_type()
# Update the field
self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
def _handle_existing_instance(self):
# == Init variables == #
# Instance is already in the database, fetch its current state
current_instance = self.sender.objects.get(id=self.instance.id)
# Check the new and old values
generic_org_type_changed = self.instance.generic_org_type != current_instance.generic_org_type
is_election_board_changed = self.instance.is_election_board != current_instance.is_election_board
organization_type_changed = self.instance.organization_type != current_instance.organization_type
# == Check for invalid conditions before proceeding == #
if organization_type_changed and (generic_org_type_changed or is_election_board_changed):
# Since organization type is linked with generic_org_type and election board,
# we have to update one or the other, not both.
# This will not happen in normal flow as it is not possible otherwise.
raise ValueError("Cannot update organization_type and generic_org_type simultaneously.")
elif not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed):
# No values to update - do nothing
return None
# == Program flow will halt here if there is no reason to update == #
# == Update the linked values == #
# Find out which field needs updating
organization_type_needs_update = generic_org_type_changed or is_election_board_changed
generic_org_type_needs_update = organization_type_changed
# Update the field
self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
def _update_fields(self, organization_type_needs_update, generic_org_type_needs_update):
"""
Validates the conditions for updating organization and generic organization types.
Raises:
ValueError: If both organization_type_needs_update and generic_org_type_needs_update are True,
indicating an attempt to update both fields simultaneously, which is not allowed.
"""
# We shouldn't update both of these at the same time.
# It is more useful to have these as seperate variables, but it does impose
# this restraint.
if organization_type_needs_update and generic_org_type_needs_update:
raise ValueError("Cannot update both org type and generic org type at the same time.")
if organization_type_needs_update:
self._update_org_type_from_generic_org_and_election()
elif generic_org_type_needs_update:
self._update_generic_org_and_election_from_org_type()
def _update_org_type_from_generic_org_and_election(self):
"""Given a field values for generic_org_type and is_election_board, update the
organization_type field."""
# We convert to a string because the enum types are different.
generic_org_type = str(self.instance.generic_org_type)
if generic_org_type not in self.generic_org_to_org_map:
# Election board should always be reset to None if the record
# can't have one. For example, federal.
if self.instance.is_election_board is not None:
# This maintains data consistency.
# There is no avenue for this to occur in the UI,
# as such - this can only occur if the object is initialized in this way.
# Or if there are pre-existing data.
logger.warning(
"create_or_update_organization_type() -> is_election_board "
f"cannot exist for {generic_org_type}. Setting to None."
)
self.instance.is_election_board = None
self.instance.organization_type = generic_org_type
else:
# This can only happen with manual data tinkering, which causes these to be out of sync.
if self.instance.is_election_board is None:
logger.warning(
"create_or_update_organization_type() -> is_election_board is out of sync. Updating value."
)
self.instance.is_election_board = False
if self.instance.is_election_board:
self.instance.organization_type = self.generic_org_to_org_map[generic_org_type]
else:
self.instance.organization_type = generic_org_type
def _update_generic_org_and_election_from_org_type(self):
"""Given the field value for organization_type, update the
generic_org_type and is_election_board field."""
# We convert to a string because the enum types are different
# between OrgChoicesElectionOffice and OrganizationChoices.
# But their names are the same (for the most part).
current_org_type = str(self.instance.organization_type)
election_org_map = self.election_org_to_generic_org_map
generic_org_map = self.generic_org_to_org_map
# This essentially means: "_election" in current_org_type.
if current_org_type in election_org_map:
new_org = election_org_map[current_org_type]
self.instance.generic_org_type = new_org
self.instance.is_election_board = True
elif self.instance.organization_type is not None:
self.instance.generic_org_type = current_org_type
# This basically checks if the given org type
# can even have an election board in the first place.
# For instance, federal cannot so is_election_board = None
if current_org_type in generic_org_map:
self.instance.is_election_board = False
else:
# This maintains data consistency.
# There is no avenue for this to occur in the UI,
# as such - this can only occur if the object is initialized in this way.
# Or if there are pre-existing data.
logger.warning(
"create_or_update_organization_type() -> is_election_board "
f"cannot exist for {current_org_type}. Setting to None."
)
self.instance.is_election_board = None
else:
# if self.instance.organization_type is set to None, then this means
# we should clear the related fields.
# This will not occur if it just is None (i.e. default), only if it is set to be so.
self.instance.is_election_board = None
self.instance.generic_org_type = None
def _validate_new_instance(self):
"""
Validates whether a new instance of DomainRequest or DomainInformation can proceed with the update
based on the consistency between organization_type, generic_org_type, and is_election_board.
Returns a boolean determining if execution should proceed or not.
"""
# We conditionally accept both of these values to exist simultaneously, as long as
# those values do not intefere with eachother.
# Because this condition can only be triggered through a dev (no user flow),
# we throw an error if an invalid state is found here.
if self.instance.organization_type and self.instance.generic_org_type:
generic_org_type = str(self.instance.generic_org_type)
organization_type = str(self.instance.organization_type)
# Strip "_election" if it exists
mapped_org_type = self.election_org_to_generic_org_map.get(organization_type)
# Do tests on the org update for election board changes.
is_election_type = "_election" in organization_type
can_have_election_board = organization_type in self.generic_org_to_org_map
election_board_mismatch = (is_election_type != self.instance.is_election_board) and can_have_election_board
org_type_mismatch = mapped_org_type is not None and (generic_org_type != mapped_org_type)
if election_board_mismatch or org_type_mismatch:
message = (
"Cannot add organization_type and generic_org_type simultaneously "
"when generic_org_type, is_election_board, and organization_type values do not match."
)
raise ValueError(message)
return True
elif not self.instance.organization_type and not self.instance.generic_org_type:
return False
else:
return True

View file

@ -27,6 +27,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</dl>
</div>
{% endif %}
{% elif field.field.name == "requested_domain" %}
{% with current_path=request.get_full_path %}
<a class="margin-top-05 padding-top-05" href="{% url 'admin:registrar_draftdomain_change' original.requested_domain.id %}?{{ 'return_path='|add:current_path }}">{{ original.requested_domain }}</a>
{% endwith%}
{% elif field.field.name == "current_websites" %}
{% comment %}
The "website" model is essentially just a text field.
@ -49,9 +53,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</div>
{% elif field.field.name == "alternative_domains" %}
<div class="readonly">
{% with current_path=request.get_full_path %}
{% for alt_domain in original.alternative_domains.all %}
<a href="{% url 'admin:registrar_website_change' alt_domain.id %}">{{ alt_domain }}</a>{% if not forloop.last %}, {% endif %}
<a href="{% url 'admin:registrar_website_change' alt_domain.id %}?{{ 'return_path='|add:current_path }}">{{ alt_domain }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% endwith %}
</div>
{% else %}
<div class="readonly">{{ field.contents }}</div>

View file

@ -3,7 +3,7 @@
<h2 class="margin-top-0 margin-bottom-2 text-primary-darker text-semibold" >
Next steps in this process
</h2>
<p>We received your .gov domain request. Our next step is to review your request. This usually takes 20 business days. Well email you if we have questions and when we complete our review. <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'contact' %}">Contact us with any questions</a>.</p>
<p>We received your .gov domain request. Our next step is to review your request. This usually takes 30 business days. Well email you if we have questions and when we complete our review. <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'contact' %}">Contact us with any questions</a>.</p>
<h2 class="margin-top-0 margin-bottom-2 text-primary-darker text-semibold">
Need to make changes?

View file

@ -585,7 +585,7 @@ class MockDb(TestCase):
generic_org_type="federal",
federal_agency="World War I Centennial Commission",
federal_type="executive",
is_election_board=True,
is_election_board=False,
)
self.domain_information_2, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain_2, generic_org_type="interstate", is_election_board=True
@ -595,14 +595,14 @@ class MockDb(TestCase):
domain=self.domain_3,
generic_org_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=True,
is_election_board=False,
)
self.domain_information_4, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_4,
generic_org_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=True,
is_election_board=False,
)
self.domain_information_5, _ = DomainInformation.objects.get_or_create(
creator=self.user,
@ -652,7 +652,7 @@ class MockDb(TestCase):
generic_org_type="federal",
federal_agency="World War I Centennial Commission",
federal_type="executive",
is_election_board=True,
is_election_board=False,
)
self.domain_information_12, _ = DomainInformation.objects.get_or_create(
creator=self.user,
@ -801,6 +801,9 @@ def completed_domain_request(
submitter=False,
name="city.gov",
investigator=None,
generic_org_type="federal",
is_election_board=False,
organization_type=None,
):
"""A completed domain request."""
if not user:
@ -838,7 +841,8 @@ def completed_domain_request(
is_staff=True,
)
domain_request_kwargs = dict(
generic_org_type="federal",
generic_org_type=generic_org_type,
is_election_board=is_election_board,
federal_type="executive",
purpose="Purpose of the site",
is_policy_acknowledged=True,
@ -859,6 +863,9 @@ def completed_domain_request(
if has_anything_else:
domain_request_kwargs["anything_else"] = "There is more"
if organization_type:
domain_request_kwargs["organization_type"] = organization_type
domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
if has_other_contacts:

View file

@ -21,7 +21,16 @@ from registrar.admin import (
UserDomainRoleAdmin,
VerifiedByStaffAdmin,
)
from registrar.models import Domain, DomainRequest, DomainInformation, User, DomainInvitation, Contact, Website
from registrar.models import (
Domain,
DomainRequest,
DomainInformation,
User,
DomainInvitation,
Contact,
Website,
DraftDomain,
)
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.verified_by_staff import VerifiedByStaff
from .common import (
@ -76,11 +85,10 @@ class TestDomainAdmin(MockEppLib, WebTest):
)
super().setUp()
@skip("TODO for another ticket. This test case is grabbing old db data.")
@patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1))
def test_extend_expiration_date_button(self, mock_date_today):
"""
Tests if extend_expiration_date button extends correctly
Tests if extend_expiration_date modal gives an accurate date
"""
# Create a ready domain with a preset expiration date
@ -107,17 +115,11 @@ class TestDomainAdmin(MockEppLib, WebTest):
# Follow the response
response = response.follow()
# refresh_from_db() does not work for objects with protected=True.
# https://github.com/viewflow/django-fsm/issues/89
new_domain = Domain.objects.get(id=domain.id)
# Check that the current expiration date is what we expect
self.assertEqual(new_domain.expiration_date, date(2025, 5, 25))
# Assert that everything on the page looks correct
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
self.assertContains(response, "New expiration date: <b>May 25, 2025</b>")
# Ensure the message we recieve is in line with what we expect
expected_message = "Successfully extended the expiration date."
@ -129,6 +131,7 @@ class TestDomainAdmin(MockEppLib, WebTest):
extra_tags="",
fail_silently=False,
)
mock_add_message.assert_has_calls([expected_call], 1)
@less_console_noise_decorator
@ -703,6 +706,126 @@ class TestDomainRequestAdmin(MockEppLib):
)
self.mock_client = MockSESClient()
@less_console_noise_decorator
def test_analyst_can_see_and_edit_alternative_domain(self):
"""Tests if an analyst can still see and edit the alternative domain field"""
# Create fake creator
_creator = User.objects.create(
username="MrMeoward",
first_name="Meoward",
last_name="Jones",
)
# Create a fake domain request
_domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
fake_website = Website.objects.create(website="thisisatest.gov")
_domain_request.alternative_domains.add(fake_website)
_domain_request.save()
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(_domain_request.pk),
follow=True,
)
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, _domain_request.requested_domain.name)
# Test if the page has the alternative domain
self.assertContains(response, "thisisatest.gov")
# Check that the page contains the url we expect
expected_href = reverse("admin:registrar_website_change", args=[fake_website.id])
self.assertContains(response, expected_href)
# Navigate to the website to ensure that we can still edit it
response = self.client.get(
"/admin/registrar/website/{}/change/".format(fake_website.pk),
follow=True,
)
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, "thisisatest.gov")
@less_console_noise_decorator
def test_analyst_can_see_and_edit_requested_domain(self):
"""Tests if an analyst can still see and edit the requested domain field"""
# Create fake creator
_creator = User.objects.create(
username="MrMeoward",
first_name="Meoward",
last_name="Jones",
)
# Create a fake domain request
_domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(_domain_request.pk),
follow=True,
)
# Filter to get the latest from the DB (rather than direct assignment)
requested_domain = DraftDomain.objects.filter(name=_domain_request.requested_domain.name).get()
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, requested_domain.name)
# Check that the page contains the url we expect
expected_href = reverse("admin:registrar_draftdomain_change", args=[requested_domain.id])
self.assertContains(response, expected_href)
# Navigate to the website to ensure that we can still edit it
response = self.client.get(
"/admin/registrar/draftdomain/{}/change/".format(requested_domain.pk),
follow=True,
)
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, "city.gov")
@less_console_noise_decorator
def test_analyst_can_see_current_websites(self):
"""Tests if an analyst can still see current website field"""
# Create fake creator
_creator = User.objects.create(
username="MrMeoward",
first_name="Meoward",
last_name="Jones",
)
# Create a fake domain request
_domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
fake_website = Website.objects.create(website="thisisatest.gov")
_domain_request.current_websites.add(fake_website)
_domain_request.save()
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(_domain_request.pk),
follow=True,
)
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, _domain_request.requested_domain.name)
# Test if the page has the current website
self.assertContains(response, "thisisatest.gov")
def test_domain_sortable(self):
"""Tests if the DomainRequest sorts by domain correctly"""
with less_console_noise():
@ -1656,6 +1779,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
"id",
"created_at",
"updated_at",
@ -1664,12 +1789,13 @@ class TestDomainRequestAdmin(MockEppLib):
"creator",
"investigator",
"generic_org_type",
"is_election_board",
"organization_type",
"federally_recognized_tribe",
"state_recognized_tribe",
"tribe_name",
"federal_agency",
"federal_type",
"is_election_board",
"organization_name",
"address_line1",
"address_line2",
@ -1704,6 +1830,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
"creator",
"about_your_organization",
"requested_domain",
@ -1729,6 +1857,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
]
self.assertEqual(readonly_fields, expected_fields)
@ -2327,6 +2457,8 @@ class TestDomainInformationAdmin(TestCase):
expected_fields = [
"other_contacts",
"generic_org_type",
"is_election_board",
"creator",
"type_of_work",
"more_organization_information",

View file

@ -37,7 +37,6 @@ class TestGroups(TestCase):
"add_domaininvitation",
"view_domaininvitation",
"change_domainrequest",
"change_draftdomain",
"add_federalagency",
"change_federalagency",
"delete_federalagency",
@ -48,7 +47,6 @@ class TestGroups(TestCase):
"add_verifiedbystaff",
"change_verifiedbystaff",
"delete_verifiedbystaff",
"change_website",
]
# Get the codenames of actual permissions associated with the group

View file

@ -1258,3 +1258,309 @@ class TestContact(TestCase):
self.contact.email = None
self.contact.phone = None
self.assertFalse(self.contact.has_contact_info())
class TestDomainRequestCustomSave(TestCase):
"""Tests custom save behaviour on the DomainRequest object"""
def tearDown(self):
DomainRequest.objects.all().delete()
super().tearDown()
def test_create_or_update_organization_type_new_instance(self):
"""Test create_or_update_organization_type when creating a new instance"""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
def test_create_or_update_organization_type_new_instance_federal_does_nothing(self):
"""Test if create_or_update_organization_type does nothing when creating a new instance for federal"""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
is_election_board=True,
)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.FEDERAL)
self.assertEqual(domain_request.is_election_board, None)
def test_create_or_update_organization_type_existing_instance_updates_election_board(self):
"""Test create_or_update_organization_type for an existing instance."""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=False,
)
domain_request.is_election_board = True
domain_request.save()
self.assertEqual(domain_request.is_election_board, True)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
# Try reverting the election board value
domain_request.is_election_board = False
domain_request.save()
self.assertEqual(domain_request.is_election_board, False)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
# Try reverting setting an invalid value for election board (should revert to False)
domain_request.is_election_board = None
domain_request.save()
self.assertEqual(domain_request.is_election_board, False)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
def test_create_or_update_organization_type_existing_instance_updates_generic_org_type(self):
"""Test create_or_update_organization_type when modifying generic_org_type on an existing instance."""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
)
domain_request.generic_org_type = DomainRequest.OrganizationChoices.INTERSTATE
domain_request.save()
# Election board should be None because interstate cannot have an election board.
self.assertEqual(domain_request.is_election_board, None)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.INTERSTATE)
# Try changing the org Type to something that CAN have an election board.
domain_request_tribal = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="startedTribal.gov",
generic_org_type=DomainRequest.OrganizationChoices.TRIBAL,
is_election_board=True,
)
self.assertEqual(
domain_request_tribal.organization_type, DomainRequest.OrgChoicesElectionOffice.TRIBAL_ELECTION
)
# Change the org type
domain_request_tribal.generic_org_type = DomainRequest.OrganizationChoices.STATE_OR_TERRITORY
domain_request_tribal.save()
self.assertEqual(domain_request_tribal.is_election_board, True)
self.assertEqual(
domain_request_tribal.organization_type, DomainRequest.OrgChoicesElectionOffice.STATE_OR_TERRITORY_ELECTION
)
def test_create_or_update_organization_type_no_update(self):
"""Test create_or_update_organization_type when there are no values to update."""
# Test for when both generic_org_type and organization_type is declared,
# and are both non-election board
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=False,
)
domain_request.save()
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
self.assertEqual(domain_request.is_election_board, False)
self.assertEqual(domain_request.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Test for when both generic_org_type and organization_type is declared,
# and are both election board
domain_request_election = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="startedElection.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
organization_type=DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION,
)
self.assertEqual(
domain_request_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
)
self.assertEqual(domain_request_election.is_election_board, True)
self.assertEqual(domain_request_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Modify an unrelated existing value for both, and ensure that everything is still consistent
domain_request.city = "Fudge"
domain_request_election.city = "Caramel"
domain_request.save()
domain_request_election.save()
self.assertEqual(domain_request.city, "Fudge")
self.assertEqual(domain_request_election.city, "Caramel")
# Test for non-election
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
self.assertEqual(domain_request.is_election_board, False)
self.assertEqual(domain_request.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Test for election
self.assertEqual(
domain_request_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
)
self.assertEqual(domain_request_election.is_election_board, True)
self.assertEqual(domain_request_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)
class TestDomainInformationCustomSave(TestCase):
"""Tests custom save behaviour on the DomainInformation object"""
def tearDown(self):
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
Domain.objects.all().delete()
super().tearDown()
def test_create_or_update_organization_type_new_instance(self):
"""Test create_or_update_organization_type when creating a new instance"""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
)
domain_information = DomainInformation.create_from_da(domain_request)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
def test_create_or_update_organization_type_new_instance_federal_does_nothing(self):
"""Test if create_or_update_organization_type does nothing when creating a new instance for federal"""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
is_election_board=True,
)
domain_information = DomainInformation.create_from_da(domain_request)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.FEDERAL)
self.assertEqual(domain_information.is_election_board, None)
def test_create_or_update_organization_type_existing_instance_updates_election_board(self):
"""Test create_or_update_organization_type for an existing instance."""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=False,
)
domain_information = DomainInformation.create_from_da(domain_request)
domain_information.is_election_board = True
domain_information.save()
self.assertEqual(domain_information.is_election_board, True)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
# Try reverting the election board value
domain_information.is_election_board = False
domain_information.save()
domain_information.refresh_from_db()
self.assertEqual(domain_information.is_election_board, False)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
# Try reverting setting an invalid value for election board (should revert to False)
domain_information.is_election_board = None
domain_information.save()
self.assertEqual(domain_information.is_election_board, False)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
def test_create_or_update_organization_type_existing_instance_updates_generic_org_type(self):
"""Test create_or_update_organization_type when modifying generic_org_type on an existing instance."""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
)
domain_information = DomainInformation.create_from_da(domain_request)
domain_information.generic_org_type = DomainRequest.OrganizationChoices.INTERSTATE
domain_information.save()
# Election board should be None because interstate cannot have an election board.
self.assertEqual(domain_information.is_election_board, None)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.INTERSTATE)
# Try changing the org Type to something that CAN have an election board.
domain_request_tribal = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="startedTribal.gov",
generic_org_type=DomainRequest.OrganizationChoices.TRIBAL,
is_election_board=True,
)
domain_information_tribal = DomainInformation.create_from_da(domain_request_tribal)
self.assertEqual(
domain_information_tribal.organization_type, DomainRequest.OrgChoicesElectionOffice.TRIBAL_ELECTION
)
# Change the org type
domain_information_tribal.generic_org_type = DomainRequest.OrganizationChoices.STATE_OR_TERRITORY
domain_information_tribal.save()
self.assertEqual(domain_information_tribal.is_election_board, True)
self.assertEqual(
domain_information_tribal.organization_type,
DomainRequest.OrgChoicesElectionOffice.STATE_OR_TERRITORY_ELECTION,
)
def test_create_or_update_organization_type_no_update(self):
"""Test create_or_update_organization_type when there are no values to update."""
# Test for when both generic_org_type and organization_type is declared,
# and are both non-election board
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=False,
)
domain_information = DomainInformation.create_from_da(domain_request)
domain_information.save()
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
self.assertEqual(domain_information.is_election_board, False)
self.assertEqual(domain_information.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Test for when both generic_org_type and organization_type is declared,
# and are both election board
domain_request_election = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="startedElection.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
organization_type=DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION,
)
domain_information_election = DomainInformation.create_from_da(domain_request_election)
self.assertEqual(
domain_information_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
)
self.assertEqual(domain_information_election.is_election_board, True)
self.assertEqual(domain_information_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Modify an unrelated existing value for both, and ensure that everything is still consistent
domain_information.city = "Fudge"
domain_information_election.city = "Caramel"
domain_information.save()
domain_information_election.save()
self.assertEqual(domain_information.city, "Fudge")
self.assertEqual(domain_information_election.city, "Caramel")
# Test for non-election
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
self.assertEqual(domain_information.is_election_board, False)
self.assertEqual(domain_information.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Test for election
self.assertEqual(
domain_information_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
)
self.assertEqual(domain_information_election.is_election_board, True)
self.assertEqual(domain_information_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)

View file

@ -591,7 +591,7 @@ class ExportDataTest(MockDb, MockEppLib):
"MANAGED DOMAINS COUNTS AT END DATE\n"
"Total,Federal,Interstate,State or territory,Tribal,County,City,"
"Special district,School district,Election office\n"
"3,2,1,0,0,0,0,0,0,2\n"
"3,2,1,0,0,0,0,0,0,0\n"
"\n"
"Domain name,Domain type,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
"Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
@ -718,7 +718,12 @@ class HelperFunctions(MockDb):
}
# Test with distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition)
expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 2]
expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
# Test without distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition)
expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
def test_get_sliced_requests(self):

View file

@ -1,6 +1,5 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from registrar.models import Contact