merge main

This commit is contained in:
Rachid Mrad 2024-06-07 13:11:35 -04:00
commit 9ed27b23e6
No known key found for this signature in database
19 changed files with 338 additions and 61 deletions

View file

@ -320,16 +320,6 @@ it may help to resync your laptop with time.nist.gov:
sudo sntp -sS time.nist.gov sudo sntp -sS time.nist.gov
``` ```
### Settings
The config for the connection pool exists inside the `settings.py` file.
| Name | Purpose |
| ------------------------ | ------------------------------------------------------------------------------------------------- |
| EPP_CONNECTION_POOL_SIZE | Determines the number of concurrent sockets that should exist in the pool. |
| POOL_KEEP_ALIVE | Determines the interval in which we ping open connections in seconds. Calculated as POOL_KEEP_ALIVE / EPP_CONNECTION_POOL_SIZE |
| POOL_TIMEOUT | Determines how long we try to keep a pool alive for, before restarting it. |
Consider updating the `POOL_TIMEOUT` or `POOL_KEEP_ALIVE` periods if the pool often restarts. If the pool only restarts after a period of inactivity, update `POOL_KEEP_ALIVE`. If it restarts during the EPP call itself, then `POOL_TIMEOUT` needs to be updated.
## Adding a S3 instance to your sandbox ## Adding a S3 instance to your sandbox
This can either be done through the CLI, or through the cloud.gov dashboard. Generally, it is better to do it through the dashboard as it handles app binding for you. This can either be done through the CLI, or through the cloud.gov dashboard. Generally, it is better to do it through the dashboard as it handles app binding for you.

View file

@ -668,3 +668,32 @@ Example: `cf ssh getgov-za`
#### Step 1: Running the script #### Step 1: Running the script
```docker-compose exec app ./manage.py populate_verification_type``` ```docker-compose exec app ./manage.py populate_verification_type```
## Copy names from contacts to users
### 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 copy_names_from_contacts_to_users --debug```
### Running locally
#### Step 1: Running the script
```docker-compose exec app ./manage.py copy_names_from_contacts_to_users --debug```
##### Optional parameters
| | Parameter | Description |
|:-:|:-------------------------- |:----------------------------------------------------------------------------|
| 1 | **debug** | Increases logging detail. Defaults to False. |

View file

@ -594,7 +594,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
None, None,
{"fields": ("username", "password", "status", "verification_type")}, {"fields": ("username", "password", "status", "verification_type")},
), ),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "email", "title")}), ("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
( (
"Permissions", "Permissions",
{ {
@ -625,7 +625,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
) )
}, },
), ),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "email", "title")}), ("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
( (
"Permissions", "Permissions",
{ {

View file

@ -10,6 +10,7 @@ from registrar.management.commands.utility.terminal_helper import (
) )
from registrar.models.contact import Contact from registrar.models.contact import Contact
from registrar.models.user import User from registrar.models.user import User
from registrar.models.utility.domain_helper import DomainHelper
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -110,13 +111,19 @@ class Command(BaseCommand):
{TerminalColors.ENDC}""", # noqa {TerminalColors.ENDC}""", # noqa
) )
# ---- UPDATE THE USER IF IT DOES NOT HAVE A FIRST AND LAST NAMES # Get the fields that exist on both User and Contact. Excludes id.
# ---- LET'S KEEP A LIGHT TOUCH common_fields = DomainHelper.get_common_fields(User, Contact)
if not eligible_user.first_name and not eligible_user.last_name: if "email" in common_fields:
# (expression has type "str | None", variable has type "str | int | Combinable") # Don't change the email field.
# so we'll ignore type common_fields.remove("email")
eligible_user.first_name = contact.first_name # type: ignore
eligible_user.last_name = contact.last_name # type: ignore for field in common_fields:
# Grab the value that contact has stored for this field
new_value = getattr(contact, field)
# Set it on the user field
setattr(eligible_user, field, new_value)
eligible_user.save() eligible_user.save()
processed_user = eligible_user processed_user = eligible_user

View file

@ -0,0 +1,131 @@
# Generated by Django 4.2.10 on 2024-05-28 14:40
from django.db import migrations, models
import phonenumber_field.modelfields
class Migration(migrations.Migration):
dependencies = [
("registrar", "0095_user_middle_name_user_title"),
]
operations = [
migrations.AlterField(
model_name="contact",
name="email",
field=models.EmailField(blank=True, max_length=320, null=True),
),
migrations.AlterField(
model_name="contact",
name="first_name",
field=models.CharField(blank=True, null=True, verbose_name="first name"),
),
migrations.AlterField(
model_name="contact",
name="last_name",
field=models.CharField(blank=True, null=True, verbose_name="last name"),
),
migrations.AlterField(
model_name="contact",
name="phone",
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None),
),
migrations.AlterField(
model_name="domaininformation",
name="organization_name",
field=models.CharField(blank=True, null=True),
),
migrations.AlterField(
model_name="domaininformation",
name="zipcode",
field=models.CharField(blank=True, max_length=10, null=True, verbose_name="zip code"),
),
migrations.AlterField(
model_name="domainrequest",
name="organization_name",
field=models.CharField(blank=True, null=True),
),
migrations.AlterField(
model_name="domainrequest",
name="zipcode",
field=models.CharField(blank=True, max_length=10, null=True, verbose_name="zip code"),
),
migrations.AlterField(
model_name="transitiondomain",
name="first_name",
field=models.CharField(
blank=True, help_text="First name / given name", null=True, verbose_name="first name"
),
),
migrations.AlterField(
model_name="transitiondomain",
name="organization_name",
field=models.CharField(blank=True, help_text="Organization name", null=True),
),
migrations.AlterField(
model_name="transitiondomain",
name="zipcode",
field=models.CharField(blank=True, help_text="Zip code", max_length=10, null=True, verbose_name="zip code"),
),
migrations.AlterField(
model_name="user",
name="phone",
field=phonenumber_field.modelfields.PhoneNumberField(
blank=True, help_text="Phone", max_length=128, null=True, region=None
),
),
migrations.AlterField(
model_name="verifiedbystaff",
name="email",
field=models.EmailField(max_length=254),
),
migrations.AddIndex(
model_name="contact",
index=models.Index(fields=["user"], name="registrar_c_user_id_4059c4_idx"),
),
migrations.AddIndex(
model_name="contact",
index=models.Index(fields=["email"], name="registrar_c_email_bde2de_idx"),
),
migrations.AddIndex(
model_name="domain",
index=models.Index(fields=["name"], name="registrar_d_name_5b1956_idx"),
),
migrations.AddIndex(
model_name="domain",
index=models.Index(fields=["state"], name="registrar_d_state_84c134_idx"),
),
migrations.AddIndex(
model_name="domaininformation",
index=models.Index(fields=["domain"], name="registrar_d_domain__88838a_idx"),
),
migrations.AddIndex(
model_name="domaininformation",
index=models.Index(fields=["domain_request"], name="registrar_d_domain__d1fba8_idx"),
),
migrations.AddIndex(
model_name="domaininvitation",
index=models.Index(fields=["status"], name="registrar_d_status_e84571_idx"),
),
migrations.AddIndex(
model_name="domainrequest",
index=models.Index(fields=["requested_domain"], name="registrar_d_request_6894eb_idx"),
),
migrations.AddIndex(
model_name="domainrequest",
index=models.Index(fields=["approved_domain"], name="registrar_d_approve_ac4c46_idx"),
),
migrations.AddIndex(
model_name="domainrequest",
index=models.Index(fields=["status"], name="registrar_d_status_a32b59_idx"),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["username"], name="registrar_u_usernam_964b1b_idx"),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["email"], name="registrar_u_email_c8f2c4_idx"),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 4.2.10 on 2024-06-06 18:38
from django.db import migrations
import phonenumber_field.modelfields
class Migration(migrations.Migration):
dependencies = [
("registrar", "0096_alter_contact_email_alter_contact_first_name_and_more"),
]
operations = [
migrations.AlterField(
model_name="user",
name="phone",
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None),
),
]

View file

@ -0,0 +1,32 @@
# Generated by Django 4.2.10 on 2024-06-07 15:27
from django.db import migrations
import django_fsm
class Migration(migrations.Migration):
dependencies = [
("registrar", "0097_alter_user_phone"),
]
operations = [
migrations.AlterField(
model_name="domainrequest",
name="status",
field=django_fsm.FSMField(
choices=[
("in review", "In review"),
("action needed", "Action needed"),
("approved", "Approved"),
("rejected", "Rejected"),
("ineligible", "Ineligible"),
("submitted", "Submitted"),
("withdrawn", "Withdrawn"),
("started", "Started"),
],
default="started",
max_length=50,
),
),
]

View file

@ -17,6 +17,14 @@ class Contact(TimeStampedModel):
will be updated if any updates are made to it through Login.gov. will be updated if any updates are made to it through Login.gov.
""" """
class Meta:
"""Contains meta information about this class"""
indexes = [
models.Index(fields=["user"]),
models.Index(fields=["email"]),
]
user = models.OneToOneField( user = models.OneToOneField(
"registrar.User", "registrar.User",
null=True, null=True,
@ -28,7 +36,6 @@ class Contact(TimeStampedModel):
null=True, null=True,
blank=True, blank=True,
verbose_name="first name", verbose_name="first name",
db_index=True,
) )
middle_name = models.CharField( middle_name = models.CharField(
null=True, null=True,
@ -38,7 +45,6 @@ class Contact(TimeStampedModel):
null=True, null=True,
blank=True, blank=True,
verbose_name="last name", verbose_name="last name",
db_index=True,
) )
title = models.CharField( title = models.CharField(
null=True, null=True,
@ -48,13 +54,11 @@ class Contact(TimeStampedModel):
email = models.EmailField( email = models.EmailField(
null=True, null=True,
blank=True, blank=True,
db_index=True,
max_length=320, max_length=320,
) )
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, null=True,
blank=True, blank=True,
db_index=True,
) )
def _get_all_relations(self): def _get_all_relations(self):
@ -119,11 +123,21 @@ class Contact(TimeStampedModel):
self.user.last_name = self.last_name self.user.last_name = self.last_name
updated = True updated = True
# Update middle_name if necessary
if not self.user.middle_name:
self.user.middle_name = self.middle_name
updated = True
# Update phone if necessary # Update phone if necessary
if not self.user.phone: if not self.user.phone:
self.user.phone = self.phone self.user.phone = self.phone
updated = True updated = True
# Update title if necessary
if not self.user.title:
self.user.title = self.title
updated = True
# Save user if any updates were made # Save user if any updates were made
if updated: if updated:
self.user.save() self.user.save()

View file

@ -65,6 +65,14 @@ class Domain(TimeStampedModel, DomainHelper):
domain meets the required checks. domain meets the required checks.
""" """
class Meta:
"""Contains meta information about this class"""
indexes = [
models.Index(fields=["name"]),
models.Index(fields=["state"]),
]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self._cache = {} self._cache = {}
super(Domain, self).__init__(*args, **kwargs) super(Domain, self).__init__(*args, **kwargs)

View file

@ -22,6 +22,16 @@ class DomainInformation(TimeStampedModel):
the domain request once approved, so copying them that way we can make changes the domain request once approved, so copying them that way we can make changes
after its approved. Most fields here are copied from DomainRequest.""" after its approved. Most fields here are copied from DomainRequest."""
class Meta:
"""Contains meta information about this class"""
indexes = [
models.Index(fields=["domain"]),
models.Index(fields=["domain_request"]),
]
verbose_name_plural = "Domain information"
StateTerritoryChoices = DomainRequest.StateTerritoryChoices StateTerritoryChoices = DomainRequest.StateTerritoryChoices
# use the short names in Django admin # use the short names in Django admin
@ -111,7 +121,6 @@ class DomainInformation(TimeStampedModel):
organization_name = models.CharField( organization_name = models.CharField(
null=True, null=True,
blank=True, blank=True,
db_index=True,
) )
address_line1 = models.CharField( address_line1 = models.CharField(
null=True, null=True,
@ -138,7 +147,6 @@ class DomainInformation(TimeStampedModel):
max_length=10, max_length=10,
null=True, null=True,
blank=True, blank=True,
db_index=True,
verbose_name="zip code", verbose_name="zip code",
) )
urbanization = models.CharField( urbanization = models.CharField(
@ -336,6 +344,3 @@ class DomainInformation(TimeStampedModel):
def _get_many_to_many_fields(): def _get_many_to_many_fields():
"""Returns a set of each field.name that has the many to many relation""" """Returns a set of each field.name that has the many to many relation"""
return {field.name for field in DomainInformation._meta.many_to_many} # type: ignore return {field.name for field in DomainInformation._meta.many_to_many} # type: ignore
class Meta:
verbose_name_plural = "Domain information"

View file

@ -15,6 +15,13 @@ logger = logging.getLogger(__name__)
class DomainInvitation(TimeStampedModel): class DomainInvitation(TimeStampedModel):
class Meta:
"""Contains meta information about this class"""
indexes = [
models.Index(fields=["status"]),
]
# Constants for status field # Constants for status field
class DomainInvitationStatus(models.TextChoices): class DomainInvitationStatus(models.TextChoices):
INVITED = "invited", "Invited" INVITED = "invited", "Invited"

View file

@ -25,6 +25,15 @@ logger = logging.getLogger(__name__)
class DomainRequest(TimeStampedModel): class DomainRequest(TimeStampedModel):
"""A registrant's domain request for a new domain.""" """A registrant's domain request for a new domain."""
class Meta:
"""Contains meta information about this class"""
indexes = [
models.Index(fields=["requested_domain"]),
models.Index(fields=["approved_domain"]),
models.Index(fields=["status"]),
]
# https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history # https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history
# If we note any performace degradation due to this addition, # If we note any performace degradation due to this addition,
# we can query the auditlogs table in admin.py and add the results to # we can query the auditlogs table in admin.py and add the results to
@ -34,14 +43,14 @@ class DomainRequest(TimeStampedModel):
# Constants for choice fields # Constants for choice fields
class DomainRequestStatus(models.TextChoices): class DomainRequestStatus(models.TextChoices):
STARTED = "started", "Started"
SUBMITTED = "submitted", "Submitted"
IN_REVIEW = "in review", "In review" IN_REVIEW = "in review", "In review"
ACTION_NEEDED = "action needed", "Action needed" ACTION_NEEDED = "action needed", "Action needed"
APPROVED = "approved", "Approved" APPROVED = "approved", "Approved"
WITHDRAWN = "withdrawn", "Withdrawn"
REJECTED = "rejected", "Rejected" REJECTED = "rejected", "Rejected"
INELIGIBLE = "ineligible", "Ineligible" INELIGIBLE = "ineligible", "Ineligible"
SUBMITTED = "submitted", "Submitted"
WITHDRAWN = "withdrawn", "Withdrawn"
STARTED = "started", "Started"
class StateTerritoryChoices(models.TextChoices): class StateTerritoryChoices(models.TextChoices):
ALABAMA = "AL", "Alabama (AL)" ALABAMA = "AL", "Alabama (AL)"
@ -331,7 +340,6 @@ class DomainRequest(TimeStampedModel):
organization_name = models.CharField( organization_name = models.CharField(
null=True, null=True,
blank=True, blank=True,
db_index=True,
) )
address_line1 = models.CharField( address_line1 = models.CharField(
@ -360,7 +368,6 @@ class DomainRequest(TimeStampedModel):
null=True, null=True,
blank=True, blank=True,
verbose_name="zip code", verbose_name="zip code",
db_index=True,
) )
urbanization = models.CharField( urbanization = models.CharField(
null=True, null=True,

View file

@ -59,7 +59,6 @@ class TransitionDomain(TimeStampedModel):
null=True, null=True,
blank=True, blank=True,
help_text="Organization name", help_text="Organization name",
db_index=True,
) )
federal_type = models.CharField( federal_type = models.CharField(
max_length=50, max_length=50,
@ -85,7 +84,6 @@ class TransitionDomain(TimeStampedModel):
blank=True, blank=True,
help_text="First name / given name", help_text="First name / given name",
verbose_name="first name", verbose_name="first name",
db_index=True,
) )
middle_name = models.CharField( middle_name = models.CharField(
null=True, null=True,
@ -136,7 +134,6 @@ class TransitionDomain(TimeStampedModel):
blank=True, blank=True,
verbose_name="zip code", verbose_name="zip code",
help_text="Zip code", help_text="Zip code",
db_index=True,
) )
def __str__(self): def __str__(self):

View file

@ -31,6 +31,17 @@ class User(AbstractUser):
will be updated if any updates are made to it through Login.gov. will be updated if any updates are made to it through Login.gov.
""" """
class Meta:
indexes = [
models.Index(fields=["username"]),
models.Index(fields=["email"]),
]
permissions = [
("analyst_access_permission", "Analyst Access Permission"),
("full_access_permission", "Full Access Permission"),
]
class VerificationTypeChoices(models.TextChoices): class VerificationTypeChoices(models.TextChoices):
""" """
Users achieve access to our system in a few different ways. Users achieve access to our system in a few different ways.
@ -76,8 +87,6 @@ class User(AbstractUser):
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, null=True,
blank=True, blank=True,
help_text="Phone",
db_index=True,
) )
middle_name = models.CharField( middle_name = models.CharField(
@ -281,9 +290,3 @@ class User(AbstractUser):
""" """
self.check_domain_invitations_on_login() self.check_domain_invitations_on_login()
class Meta:
permissions = [
("analyst_access_permission", "Analyst Access Permission"),
("full_access_permission", "Full Access Permission"),
]

View file

@ -9,7 +9,6 @@ class VerifiedByStaff(TimeStampedModel):
email = models.EmailField( email = models.EmailField(
null=False, null=False,
blank=False, blank=False,
db_index=True,
) )
requestor = models.ForeignKey( requestor = models.ForeignKey(

View file

@ -24,9 +24,11 @@ def handle_profile(sender, instance, **kwargs):
""" """
first_name = getattr(instance, "first_name", "") first_name = getattr(instance, "first_name", "")
middle_name = getattr(instance, "middle_name", "")
last_name = getattr(instance, "last_name", "") last_name = getattr(instance, "last_name", "")
email = getattr(instance, "email", "") email = getattr(instance, "email", "")
phone = getattr(instance, "phone", "") phone = getattr(instance, "phone", "")
title = getattr(instance, "title", "")
is_new_user = kwargs.get("created", False) is_new_user = kwargs.get("created", False)
@ -39,9 +41,11 @@ def handle_profile(sender, instance, **kwargs):
Contact.objects.create( Contact.objects.create(
user=instance, user=instance,
first_name=first_name, first_name=first_name,
middle_name=middle_name,
last_name=last_name, last_name=last_name,
email=email, email=email,
phone=phone, phone=phone,
title=title,
) )
if len(contacts) >= 1 and is_new_user: # a matching contact if len(contacts) >= 1 and is_new_user: # a matching contact

View file

@ -3534,7 +3534,7 @@ class TestMyUserAdmin(TestCase):
) )
}, },
), ),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "email", "title")}), ("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
("Permissions", {"fields": ("is_active", "groups")}), ("Permissions", {"fields": ("is_active", "groups")}),
("Important dates", {"fields": ("last_login", "date_joined")}), ("Important dates", {"fields": ("last_login", "date_joined")}),
) )

View file

@ -23,15 +23,32 @@ class TestDataUpdates(TestCase):
self.bs_user = User.objects.create() self.bs_user = User.objects.create()
self.contact1 = Contact.objects.create( self.contact1 = Contact.objects.create(
user=self.user1, email="email1@igorville.gov", first_name="first1", last_name="last1" user=self.user1,
email="email1@igorville.gov",
first_name="first1",
last_name="last1",
middle_name="middle1",
title="title1",
) )
self.contact2 = Contact.objects.create( self.contact2 = Contact.objects.create(
user=self.user2, email="email2@igorville.gov", first_name="first2", last_name="last2" user=self.user2,
email="email2@igorville.gov",
first_name="first2",
last_name="last2",
middle_name="middle2",
title="title2",
) )
self.contact3 = Contact.objects.create( self.contact3 = Contact.objects.create(
user=self.user3, email="email3@igorville.gov", first_name="first3", last_name="last3" user=self.user3,
email="email3@igorville.gov",
first_name="first3",
last_name="last3",
middle_name="middle3",
title="title3",
)
self.contact4 = Contact.objects.create(
email="email4@igorville.gov", first_name="first4", last_name="last4", middle_name="middle4", title="title4"
) )
self.contact4 = Contact.objects.create(email="email4@igorville.gov", first_name="first4", last_name="last4")
self.command = Command() self.command = Command()
@ -42,14 +59,15 @@ class TestDataUpdates(TestCase):
Contact.objects.all().delete() Contact.objects.all().delete()
def test_script_updates_linked_users(self): def test_script_updates_linked_users(self):
"""Test the script that copies contacts' first and last names into associated users that """Test the script that copies contact information to the user object"""
are eligible (first or last are blank or undefined)"""
# Set up the users' first and last names here so # Set up the users' first and last names here so
# they that they don't get overwritten by Contact's save() # they that they don't get overwritten by Contact's save()
# User with no first or last names # User with no first or last names
self.user1.first_name = "" self.user1.first_name = ""
self.user1.last_name = "" self.user1.last_name = ""
self.user1.title = "dummytitle"
self.user1.middle_name = "dummymiddle"
self.user1.save() self.user1.save()
# User with a first name but no last name # User with a first name but no last name
@ -87,12 +105,20 @@ class TestDataUpdates(TestCase):
# The user that has no first and last names will get them from the contact # The user that has no first and last names will get them from the contact
self.assertEqual(self.user1.first_name, "first1") self.assertEqual(self.user1.first_name, "first1")
self.assertEqual(self.user1.last_name, "last1") self.assertEqual(self.user1.last_name, "last1")
# The user that has a first but no last will be left alone self.assertEqual(self.user1.middle_name, "middle1")
self.assertEqual(self.user2.first_name, "First name but no last name") self.assertEqual(self.user1.title, "title1")
self.assertEqual(self.user2.last_name, "") # The user that has a first but no last will be updated
# The user that has a first and a last will be left alone self.assertEqual(self.user2.first_name, "first2")
self.assertEqual(self.user3.first_name, "An existing first name") self.assertEqual(self.user2.last_name, "last2")
self.assertEqual(self.user3.last_name, "An existing last name") self.assertEqual(self.user2.middle_name, "middle2")
self.assertEqual(self.user2.title, "title2")
# The user that has a first and a last will be updated
self.assertEqual(self.user3.first_name, "first3")
self.assertEqual(self.user3.last_name, "last3")
self.assertEqual(self.user3.middle_name, "middle3")
self.assertEqual(self.user3.title, "title3")
# The unlinked user will be left alone # The unlinked user will be left alone
self.assertEqual(self.user4.first_name, "") self.assertEqual(self.user4.first_name, "")
self.assertEqual(self.user4.last_name, "") self.assertEqual(self.user4.last_name, "")
self.assertEqual(self.user4.middle_name, None)
self.assertEqual(self.user4.title, None)

View file

@ -455,7 +455,6 @@ def export_data_full_to_csv(csv_file):
def export_data_federal_to_csv(csv_file): def export_data_federal_to_csv(csv_file):
"""Federal domains report""" """Federal domains report"""
writer = csv.writer(csv_file) writer = csv.writer(csv_file)
# define columns to include in export # define columns to include in export
columns = [ columns = [