-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Refactor project/views.py and related code. (#1517)
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
Showing
24 changed files
with
932 additions
and
898 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ | |
exclude = | ||
*/migrations/*.py, | ||
.venv, | ||
node_modules | ||
node_modules, | ||
coverage | ||
|
||
max-line-length = 100 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 == {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.