Skip to content

Commit

Permalink
New forms for FOIA/privacy requests (#6759)
Browse files Browse the repository at this point in the history
The FOIA and Privacy teams requested a fillable form to allow users to request records online, to fulfill a new requirement from OMB, due today. The two new forms, at `/privacy/records-access/` and `/privacy/disclosure-consent/`, send an email of their contents to the FOIA inbox, including any uploaded image files as attachments.

Additions:
- New pages at `/privacy/records-access/` and `/privacy/disclosure-consent`
- New django forms, `RecordsAcessForm` and `DisclosureConsentForm`, with tests
- Basic HTML email templates for sending the form contents as email
- Custom JS to add fields (mailing address) to the page only when they are required
- A basic file upload widget

Changes:
- Updates the Apache header `LIMIT_REQUEST_BODY` on the new privacy form pages so that users can upload files
  • Loading branch information
wpears authored Nov 12, 2021
1 parent 3afcc07 commit 2007ac8
Show file tree
Hide file tree
Showing 24 changed files with 1,391 additions and 7 deletions.
16 changes: 12 additions & 4 deletions cfgov/apache/conf.d/headers.conf
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@ Header always set X-Frame-Options SAMEORIGIN
Header always set X-XSS-Protection: "1; mode=block"
Header always set X-Content-Type-Options: nosniff

# Add LimitRequestBody when LIMIT_REQUEST_BODY env var is set
<If "-n osenv('LIMIT_REQUEST_BODY')">
LimitRequestBody "${LIMIT_REQUEST_BODY}"
</If>
# In prod, LIMIT_REQUEST_BODY is set to 100KB. In all other envs,
# it is set to 0, meaning no limit.
LimitRequestBody "${LIMIT_REQUEST_BODY}"

# For the privacy act request forms, which accept uploaded files,
# increase the request body limit to 8 MB.
<Location "/privacy/disclosure-consent">
LimitRequestBody 8388608
</Location>
<Location "/privacy/records-access">
LimitRequestBody 8388608
</Location>

# Add X-Forwarded headers when APACHE_HTTPS_FORWARDED_HOST envvar is set
<If "-n osenv('APACHE_HTTPS_FORWARDED_HOST')">
Expand Down
1 change: 1 addition & 0 deletions cfgov/cfgov/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"hmda",
"youth_employment",
"diversity_inclusion",
"privacy",
"mega_menu.apps.MegaMenuConfig",
"form_explainer.apps.FormExplainerConfig",
"teachers_digital_platform",
Expand Down
7 changes: 7 additions & 0 deletions cfgov/cfgov/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,13 @@ def empty_200_response(request, *args, **kwargs):
'diversity_inclusion'),
namespace='diversity_inclusion')),

re_path(
r'^privacy/',
include((
'privacy.urls',
'privacy'),
namespace='privacy')),

re_path(r'^sitemap\.xml$', akamai_no_store(sitemap)),

re_path(
Expand Down
Empty file added cfgov/privacy/__init__.py
Empty file.
197 changes: 197 additions & 0 deletions cfgov/privacy/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
from django import forms
from django.conf import settings
from django.core.mail import BadHeaderError, EmailMessage
from django.core.validators import validate_image_file_extension
from django.http import HttpResponse
from django.template import loader


# Form input attributes for Design System compatibility.
# Technique copied from data_research/forms.py
#
# See https://cfpb.github.io/design-system/components/text-inputs
# for documentation on the styles that are being duplicated here.
text_input_attrs = {
'class': 'a-text-input a-text-input__full',
}
address_attrs = {'class': 'a-text-input'}


class PrivacyActForm(forms.Form):
# Form fields
description = forms.CharField(
label='Description of the record(s) sought',
widget=forms.Textarea(attrs=text_input_attrs),
)
system_of_record = forms.CharField(
label=('Name of the system of records you believe contain the '
'record(s)'),
required=False,
widget=forms.TextInput(attrs=text_input_attrs),
)
date_of_records = forms.CharField(
label='Date of the record(s)',
help_text=('Or the period in which you believe that the record was '
'created'),
required=False,
widget=forms.TextInput(attrs=text_input_attrs),
)
other_info = forms.CharField(
label='Any additional information',
help_text=('This may include maiden name, dates of employment, '
'account information, etc.'),
required=False,
widget=forms.Textarea(attrs=text_input_attrs),
)
requestor_name = forms.CharField(
label='Name of requestor',
widget=forms.TextInput(attrs=text_input_attrs),
)
requestor_email = forms.EmailField(
label='Email address',
widget=forms.EmailInput(attrs=text_input_attrs),
)
contact_channel = forms.ChoiceField(
choices=[
('email', 'Please send my records via email'),
('mail', 'Please send my records by mail'),
]
)
street_address = forms.CharField(
required=False,
widget=forms.TextInput(attrs=text_input_attrs),
)
city = forms.CharField(
required=False,
widget=forms.TextInput(attrs=address_attrs),
)
state = forms.CharField(
required=False,
widget=forms.TextInput(attrs=address_attrs),
)
zip_code = forms.CharField(
label='Zip',
required=False,
widget=forms.TextInput(attrs=address_attrs),
)
supporting_documentation = forms.FileField(
required=False,
validators=[validate_image_file_extension],
)
full_name = forms.CharField(
label='Full name',
widget=forms.TextInput(attrs=text_input_attrs),
)

# Form validations
def require_address_if_mailing(self):
data = self.cleaned_data
msg = "Mailing address is required if requesting records by mail."
if data['contact_channel'] == 'mail':
if not (data['street_address'] and data['city'] and data['state']
and data['zip_code']):
self.add_error('street_address', forms.ValidationError(msg))

def combined_file_size(self, files):
total = 0
for f in files:
total += f.size
return total

def limit_file_size(self, files):
mb = 1048576 # one megabyte in bytes
max_bytes = mb * 6 # 6 MB
total_uploaded_bytes = self.combined_file_size(files)
if total_uploaded_bytes > max_bytes:
display_size = round(total_uploaded_bytes / mb, 1)
err = forms.ValidationError(
f"Total size of uploaded files ({display_size} MB) was "
"greater than size limit (2 MB)."
)
self.add_error('supporting_documentation', err)

def limit_number_of_files(self, files):
max_files = 6
if len(files) > max_files:
err = forms.ValidationError(
f"Please choose {max_files} or fewer files. "
f"You chose {len(files)}."
)
self.add_error('supporting_documentation', err)

def clean(self):
super().clean()
self.require_address_if_mailing()
uploaded_files = self.files.getlist('supporting_documentation')
self.limit_file_size(uploaded_files)
self.limit_number_of_files(uploaded_files)

# Email message
def send_email(self):
uploaded_files = self.files.getlist('supporting_documentation')
data = self.cleaned_data
data.update({'uploaded_files': uploaded_files})
subject = self.format_subject(data['requestor_name'])
from_email = settings.DEFAULT_FROM_EMAIL
recipient_list = ['[email protected]']

body = self.email_body(data)

email = EmailMessage(
subject,
body,
from_email,
recipient_list,
reply_to=[data['requestor_email']]
)
email.content_subtype = 'html'

for f in uploaded_files:
email.attach(f.name, f.read(), f.content_type)

try:
email.send()
except BadHeaderError:
return HttpResponse('Invalid header found.')


class DisclosureConsentForm(PrivacyActForm):
consent = forms.BooleanField(
widget=forms.CheckboxInput(attrs={'class': 'a-checkbox'}),
)
# Additional fields beyond what's defined in PrivacyActForm
recipient_name = forms.CharField(
label='Name of recipient',
widget=forms.TextInput(attrs=text_input_attrs),
)
recipient_email = forms.EmailField(
label='Email address',
widget=forms.EmailInput(attrs=text_input_attrs),
)

def format_subject(self, name):
return f'Disclosure request from consumerfinance.gov: {name}'

email_template = 'privacy/disclosure_consent_email.html'

def email_body(self, data):
num_files = len(data['uploaded_files'])
data.update({'num_files': num_files})
return loader.render_to_string(self.email_template, data)


class RecordsAccessForm(PrivacyActForm):
# Inherit form fields from the PrivacyActForm class
consent = forms.BooleanField(
widget=forms.CheckboxInput(attrs={'class': 'a-checkbox'}),
)

def format_subject(self, name):
return f'Records request from consumerfinance.gov: {name}'

email_template = 'privacy/records_access_email.html'

def email_body(self, data):
num_files = len(data['uploaded_files'])
data.update({'num_files': num_files})
return loader.render_to_string(self.email_template, data)
12 changes: 12 additions & 0 deletions cfgov/privacy/jinja2/privacy/_sidebar.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<div style="margin-bottom:2.8125em" class="block block__flush-top">
<h3>About us</h3>
<p>
We're the Consumer Financial Protection Bureau (CFPB), a U.S. government agency that makes sure banks, lenders, and other financial companies treat you fairly.
</p>
<a class="a-btn a-btn__link"
href="/about-us/the-bureau/"
rel="noopener noreferrer"
target="_blank">
Learn how the CFPB can help you
</a>
</div>
16 changes: 16 additions & 0 deletions cfgov/privacy/jinja2/privacy/consent_disclosure.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% macro render() %}
<span>
I declare under penalty of perjury under the laws
of the United States of America that the foregoing is true and
correct, and that I am the person named above and consenting
to and authorizing disclosure of my records [<i>or records
that I am entitled to request as the parent of a minor or
the legal guardian of an incompetent</i>], and I understand that
any falsification of this statement is punishable under the
provisions of 18 U.S.C. § 1001 by a fine, imprisonment of
not more than five years, or both, and that requesting or
obtaining any record(s) under false pretenses is punishable
under the provisions of 5 U.S.C. § 552a(i)(3) by a fine of not
more than $5,000.
</span>
{% endmacro %}
15 changes: 15 additions & 0 deletions cfgov/privacy/jinja2/privacy/consent_individual.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% macro render() %}
<span>
I declare under penalty of perjury under the laws of the United
States of America that the foregoing is true and correct, and that
I am the person named above and requesting access to my records
[<i>or records that I am entitled to request as the parent of a minor
or the legal guardian of an incompetent</i>], and I understand that
any falsification of this statement is punishable under the
provisions of 18 U.S.C. § 1001 by a fine, imprisonment of not
more than five years, or both, and that requesting or obtaining
any record(s) under false pretenses is punishable under the
provisions of 5 U.S.C. § 552a(i)(3) by a fine of not more than
$5,000.
</span>
{% endmacro %}
Loading

0 comments on commit 2007ac8

Please sign in to comment.