Skip to content

Commit

Permalink
Refactor project/views.py and related code. (#1517)
Browse files Browse the repository at this point in the history
Currently our `project` directory is getting pretty bloated, and it's becoming harder to add functionality to our React rendering code without accidentally introducing a circular import.

This makes things easier to understand and maintain by moving most of the Django-React integration code into the `frontend` directory, closer to where the front-end code it integrates with is located.
  • Loading branch information
toolness authored Jun 3, 2020
1 parent bd7dc0b commit 5225881
Show file tree
Hide file tree
Showing 24 changed files with 932 additions and 898 deletions.
3 changes: 2 additions & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
exclude =
*/migrations/*.py,
.venv,
node_modules
node_modules,
coverage

max-line-length = 100
2 changes: 1 addition & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ def allow_lambda_http(requests_mock):
to it, instead of doing any mocking.
'''

from project.views import lambda_service
from frontend.views import lambda_service
from project.util.lambda_http_client import LambdaHttpClient

if isinstance(lambda_service, LambdaHttpClient):
Expand Down
59 changes: 59 additions & 0 deletions frontend/graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from pathlib import Path
from typing import List, Dict, Any

import re

MY_DIR = Path(__file__).parent.resolve()

FRONTEND_QUERY_DIR = MY_DIR / 'lib' / 'queries' / 'autogen'


def find_all_graphql_fragments(query: str) -> List[str]:
'''
>>> find_all_graphql_fragments('blah')
[]
>>> find_all_graphql_fragments('query { ...Thing,\\n ...OtherThing }')
['Thing', 'OtherThing']
'''

results = re.findall(r'\.\.\.([A-Za-z0-9_]+)', query)
return [thing for thing in results]


def add_graphql_fragments(query: str) -> str:
all_graphql = [query]
to_find = find_all_graphql_fragments(query)

while to_find:
fragname = to_find.pop()
fragpath = FRONTEND_QUERY_DIR / f"{fragname}.graphql"
fragtext = fragpath.read_text()
to_find.extend(find_all_graphql_fragments(fragtext))
all_graphql.append(fragtext)

return '\n'.join(all_graphql)


def execute_query(request, query: str, variables=None) -> Dict[str, Any]:
# We're importing this in this function to avoid a circular
# imports by code that needs to import this module.
from project.schema import schema

result = schema.execute(query, context=request, variables=variables)
if result.errors:
raise Exception(result.errors)
return result.data


def get_initial_session(request) -> Dict[str, Any]:
data = execute_query(
request,
add_graphql_fragments('''
query GetInitialSession {
session {
...AllSessionInfo
}
}
''')
)
return data['session']
File renamed without changes.
54 changes: 54 additions & 0 deletions frontend/legacy_forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from typing import Dict, Any

from project.util import django_graphql_forms
from project import common_data
from .graphql import execute_query

FORMS_COMMON_DATA = common_data.load_json("forms.json")


class LegacyFormSubmissionError(Exception):
pass


def fix_newlines(d: Dict[str, str]) -> Dict[str, str]:
result = dict()
result.update(d)
for key in d:
result[key] = result[key].replace('\r\n', '\n')
return result


def get_legacy_form_submission_result(request, graphql, input):
if request.POST.get(FORMS_COMMON_DATA["LEGACY_FORMSET_ADD_BUTTON_NAME"]):
return None
return execute_query(request, graphql, variables={'input': input})['output']


def get_legacy_form_submission(request) -> Dict[str, Any]:
graphql = request.POST.get('graphql')

if not graphql:
raise LegacyFormSubmissionError('No GraphQL query found')

input_type = django_graphql_forms.get_input_type_from_query(graphql)

if not input_type:
raise LegacyFormSubmissionError('Invalid GraphQL query')

form_class = django_graphql_forms.get_form_class_for_input_type(input_type)

if not form_class:
raise LegacyFormSubmissionError('Invalid GraphQL input type')

formset_classes = django_graphql_forms.get_formset_classes_for_input_type(input_type)
exclude_fields = django_graphql_forms.get_exclude_fields_for_input_type(input_type)

input = django_graphql_forms.convert_post_data_to_input(
form_class, request.POST, formset_classes, exclude_fields)

return {
'input': input,
'result': get_legacy_form_submission_result(request, graphql, input),
'POST': fix_newlines(request.POST.dict())
}
File renamed without changes.
Empty file added frontend/tests/__init__.py
Empty file.
36 changes: 36 additions & 0 deletions frontend/tests/test_graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import pytest

from users.tests.factories import UserFactory
from frontend.graphql import execute_query, get_initial_session
from project.graphql_static_request import GraphQLStaticRequest


def test_execute_query_raises_exception_on_errors(graphql_client):
with pytest.raises(Exception) as exc_info:
execute_query(graphql_client.request, 'bloop')
assert 'bloop' in str(exc_info.value)


def test_get_initial_session_works(db, graphql_client):
request = graphql_client.request
assert len(get_initial_session(request)['csrfToken']) > 0


class TestGraphQLStaticRequest:
def test_get_initial_session_works_with_anonymous_user(self, db):
request = GraphQLStaticRequest()
session = get_initial_session(request)

assert session['firstName'] is None
assert session['csrfToken'] == ''
assert session['isSafeModeEnabled'] is False
assert request.session == {}

def test_get_initial_session_works_with_authenticated_user(self, db):
request = GraphQLStaticRequest(user=UserFactory())
session = get_initial_session(request)

assert session['firstName'] == 'Boop'
assert session['csrfToken'] == ''
assert session['isSafeModeEnabled'] is False
assert request.session == {}
208 changes: 208 additions & 0 deletions frontend/tests/test_legacy_forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import pytest

from users.tests.factories import UserFactory
from project.tests.util import qdict
from frontend.legacy_forms import (
fix_newlines,
LegacyFormSubmissionError,
get_legacy_form_submission,
FORMS_COMMON_DATA
)
from .util import react_url


@pytest.fixture(autouse=True)
def setup_fixtures(allow_lambda_http, db):
pass


def unmunge_form_graphql(form):
# Sometimes browsers will munge the newlines in our own
# hidden inputs before submitting; let's make sure that
# we account for that.
assert '\r\n' not in form['graphql'].value
assert '\n' in form['graphql'].value
form['graphql'] = form['graphql'].value.replace('\n', '\r\n')


def test_fix_newlines_works():
assert fix_newlines({'boop': 'hello\r\nthere'}) == {'boop': 'hello\nthere'}


def test_get_legacy_form_submission_raises_errors(graphql_client):
request = graphql_client.request
with pytest.raises(LegacyFormSubmissionError, match='No GraphQL query found'):
get_legacy_form_submission(request)

request.POST = qdict({'graphql': ['boop']})

with pytest.raises(LegacyFormSubmissionError, match='Invalid GraphQL query'):
get_legacy_form_submission(request)

request.POST = qdict({'graphql': ['''
mutation Foo($input: NonExistentInput!) { foo(input: $input) }
''']})

with pytest.raises(LegacyFormSubmissionError, match='Invalid GraphQL input type'):
get_legacy_form_submission(request)


def test_form_submission_in_modal_redirects_on_success(django_app):
form = django_app.get('/dev/examples/form/in-modal').forms[0]

unmunge_form_graphql(form)
form['exampleField'] = 'hi'
response = form.submit()
assert response.status == '302 Found'
assert response['Location'] == '/dev/examples/form'


def test_form_submission_redirects_on_success(django_app):
form = django_app.get('/dev/examples/form').forms[0]

unmunge_form_graphql(form)
form['exampleField'] = 'hi'
response = form.submit()
assert response.status == '302 Found'
assert response['Location'] == react_url('/')


def test_form_submission_in_modal_shows_success_message(django_app):
form = django_app.get('/dev/examples/form2/in-modal').forms[0]

unmunge_form_graphql(form)
form['exampleField'] = 'zzz'
response = form.submit()
assert response.status == '200 OK'
assert 'the form was submitted successfully' in response
assert 'hello there zzz' in response


def test_form_submission_shows_success_message(django_app):
form = django_app.get('/dev/examples/form2').forms[0]

unmunge_form_graphql(form)
form['exampleField'] = 'yyy'
response = form.submit()
assert response.status == '200 OK'
assert 'the form was submitted successfully' in response
assert 'hello there yyy' in response


def test_form_submission_shows_errors(django_app):
response = django_app.get('/dev/examples/form')
assert response.status == '200 OK'

form = response.forms[0]
form['exampleField'] = 'hello there buddy'
response = form.submit()

assert response.status == '200 OK'
form = response.forms[0]

# Ensure the form preserves the input from our last submission.
assert form['exampleField'].value == 'hello there buddy'

assert 'Ensure this value has at most 5 characters (it has 17)' in response


class TestRadio:
@pytest.fixture(autouse=True)
def set_django_app(self, django_app):
self.django_app = django_app
self.form = self.django_app.get('/dev/examples/radio').forms[0]

def test_it_works(self):
self.form['radioField'] = 'A'
response = self.form.submit()
assert response.status == '302 Found'

def test_it_shows_error_when_not_filled_out(self):
response = self.form.submit()
assert response.status == '200 OK'
assert 'This field is required' in response


class TestFormsets:
@pytest.fixture(autouse=True)
def set_django_app(self, django_app):
self.django_app = django_app
self.form = self.django_app.get('/dev/examples/form').forms[0]
# Make the non-formset fields valid. (Yes, this is a code smell.)
self.form['exampleField'] = 'hi'

def test_it_works(self):
self.form['subforms-0-exampleField'] = 'boop'
response = self.form.submit()
assert response.status == '302 Found'

def test_it_shows_non_field_errors(self):
self.form['subforms-0-exampleField'] = 'NFIER'
response = self.form.submit()
assert response.status == '200 OK'
assert 'This is an example non-field error' in response

def test_it_shows_non_form_errors(self):
self.form['subforms-0-exampleField'] = 'NFOER'
response = self.form.submit()
assert response.status == '200 OK'
assert 'This is an example non-form error' in response
assert 'CODE_NFOER' in response

def test_it_shows_field_errors(self):
self.form['subforms-0-exampleField'] = 'hello there buddy'
response = self.form.submit()
assert response.status == '200 OK'
assert 'Ensure this value has at most 5 characters (it has 17)' in response

def test_add_another_works(self):
second_field = 'subforms-1-exampleField'
assert second_field not in self.form.fields
self.form['subforms-0-exampleField'] = 'boop'
response = self.form.submit(FORMS_COMMON_DATA["LEGACY_FORMSET_ADD_BUTTON_NAME"])
assert response.status == '200 OK'
assert second_field in response.forms[0].fields


def test_form_submission_preserves_boolean_fields(django_app):
form = django_app.get('/dev/examples/form').forms[0]

assert form['boolField'].value is None
form['boolField'] = True
response = form.submit()

assert response.status == '200 OK'
form = response.forms[0]

assert form['boolField'].value == 'on'
form['boolField'] = False
response = form.submit()

assert response.status == '200 OK'
form = response.forms[0]
assert form['boolField'].value is None


@pytest.mark.django_db
def test_successful_login_redirects_to_next(django_app):
UserFactory(phone_number='5551234567', password='test123')
form = django_app.get(react_url('/login') + '?next=/boop').forms[0]

form['phoneNumber'] = '5551234567'
form['password'] = 'test123'
response = form.submit()

assert response.status == '302 Found'
assert response['Location'] == 'http://testserver/boop'


@pytest.mark.django_db
def test_unsuccessful_login_shows_error(django_app):
form = django_app.get(react_url('/login') + '?next=/boop').forms[0]

form['phoneNumber'] = '5551234567'
form['password'] = 'test123'
response = form.submit()

assert response.status == '200 OK'
assert 'Invalid phone number or password' in response
Loading

0 comments on commit 5225881

Please sign in to comment.