├── .coveragerc
├── .gitignore
├── LICENSE
├── README.md
├── pyproject.toml
├── setup.cfg
├── tests.sh
├── tests
├── __init__.py
├── conftest.py
├── graphql
│ ├── __init__.py
│ ├── children.graphql
│ ├── children.json
│ ├── document_1.graphql
│ ├── document_1.json
│ ├── documents_all.graphql
│ ├── documents_all.json
│ ├── image_1.graphql
│ ├── image_1.json
│ ├── image_1_rendition.graphql
│ ├── image_1_rendition.json
│ ├── image_2.graphql
│ ├── image_2.json
│ ├── images_all.graphql
│ ├── images_all.json
│ ├── menus_all.graphql
│ ├── menus_all.json
│ ├── prefetch.graphql
│ ├── prefetch.json
│ ├── settings.graphql
│ ├── settings.json
│ ├── settings_fail.graphql
│ ├── settings_fail.json
│ ├── settings_relay.graphql
│ ├── settings_relay.json
│ ├── showmenus.graphql
│ ├── showmenus.json
│ ├── site.graphql
│ ├── site.json
│ ├── snippets_1.graphql
│ ├── snippets_1.json
│ ├── snippets_1_relay.graphql
│ ├── snippets_1_relay.json
│ ├── test_app_1_get_home.graphql
│ ├── test_app_1_get_home.json
│ ├── test_app_1_get_home_latest.graphql
│ ├── test_app_1_get_home_latest.json
│ ├── test_app_1_get_home_latest_relay.graphql
│ ├── test_app_1_get_home_latest_relay.json
│ ├── test_app_1_get_home_none.graphql
│ ├── test_app_1_get_home_none.json
│ ├── test_app_1_get_home_relay.graphql
│ ├── test_app_1_get_home_relay.json
│ ├── test_app_1_get_home_revision.graphql
│ ├── test_app_1_get_home_revision.json
│ ├── test_app_1_get_home_revision_fail.graphql
│ ├── test_app_1_get_home_revision_fail.json
│ ├── test_app_1_get_home_revision_relay.graphql
│ ├── test_app_1_get_home_revision_relay.json
│ ├── test_app_1_get_pages.graphql
│ ├── test_app_1_get_pages.json
│ ├── test_app_1_get_pages_parent.graphql
│ ├── test_app_1_get_pages_parent.json
│ ├── test_app_1_get_pages_parent_fail.graphql
│ ├── test_app_1_get_pages_parent_fail.json
│ ├── test_app_1_get_pages_relay.graphql
│ ├── test_app_1_get_pages_relay.json
│ ├── test_app_2_streamfield.graphql
│ ├── test_app_2_streamfield.json
│ ├── test_app_2_streamfield_relay.graphql
│ ├── test_app_2_streamfield_relay.json
│ ├── test_user_admin.graphql
│ ├── test_user_admin.json
│ ├── test_user_anonymous.graphql
│ └── test_user_anonymous.json
├── test_auth.py
├── test_basic_pages.py
├── test_documents.py
├── test_forms.py
├── test_images.py
├── test_menus.py
├── test_permissions.py
├── test_project
│ ├── .gitignore
│ ├── db.sqlite3
│ ├── manage.py
│ ├── project
│ │ ├── __init__.py
│ │ ├── settings
│ │ │ ├── __init__.py
│ │ │ ├── base.py
│ │ │ ├── relay.py
│ │ │ └── test.py
│ │ ├── urls.py
│ │ └── wsgi.py
│ ├── test_app_1
│ │ ├── __init__.py
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_create_homepage.py
│ │ │ └── __init__.py
│ │ ├── models.py
│ │ └── templates
│ │ │ └── home
│ │ │ └── home_page.html
│ └── test_app_2
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── models.py
│ │ ├── tests.py
│ │ └── views.py
├── test_registry.py
├── test_settings.py
├── test_site.py
├── test_snippets.py
├── test_streamfield.py
└── test_wagtail_graphql.py
└── wagtail_graphql
├── __init__.py
├── actions.py
├── apps.py
├── permissions.py
├── registry.py
├── relay.py
├── schema.py
├── settings.py
└── types
├── __init__.py
├── auth.py
├── converters.py
├── core.py
├── documents.py
├── forms.py
├── images.py
├── menus.py
├── settings.py
├── snippets.py
└── streamfield.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
3 | include = wagtail_graphql/*
4 | omit =
5 |
6 | [html]
7 | directory = cover
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.egg
2 | *.egg-info
3 | *.pyc
4 | *$py.class
5 | *~
6 | .coverage
7 | coverage.xml
8 | build/
9 | dist/
10 | .tox/
11 | env*/
12 | tmp/
13 | .venv*
14 | .cache
15 | .eggs
16 | cover
17 | .pytest*
18 | .mypy_cache
19 | .idea
20 | poetry.lock
21 | htmlcov/
22 | pip-wheel-metadata
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Tiago Requeijo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
test
", 42 | "fieldRegex": "234", 43 | "fieldRich": "ffdsdfgdfjg
this is a paragraph
" 347 | }, 348 | { 349 | "__typename": "StringBlock", 350 | "field": "quote", 351 | "valString": "quote test" 352 | }, 353 | { 354 | "__typename": "StringBlock", 355 | "field": "choice", 356 | "valString": "tea" 357 | }, 358 | { 359 | "__typename": "StringBlock", 360 | "field": "static", 361 | "valString": null 362 | } 363 | ], 364 | "title": "TEST", 365 | "urlPath": "/test" 366 | } 367 | } 368 | } -------------------------------------------------------------------------------- /tests/graphql/test_app_2_streamfield_relay.graphql: -------------------------------------------------------------------------------- 1 | { 2 | page(id: 4) { 3 | __typename 4 | id 5 | title 6 | urlPath 7 | contentType 8 | slug 9 | path 10 | depth 11 | seoTitle 12 | numchild 13 | revision 14 | firstPublishedAt 15 | lastPublishedAt 16 | latestRevisionCreatedAt 17 | live 18 | goLiveAt 19 | expireAt 20 | expired 21 | locked 22 | draftTitle 23 | hasUnpublishedChanges 24 | ... on Test_app_2PageTypeA { 25 | streamfield { 26 | __typename 27 | ... on StringBlock { 28 | val: value 29 | field 30 | } 31 | ... on IntBlock { 32 | value 33 | field 34 | } 35 | } 36 | another { 37 | __typename 38 | ... on StringBlock { 39 | val: value 40 | field 41 | } 42 | ... on IntBlock { 43 | value 44 | field 45 | } 46 | } 47 | third { 48 | __typename 49 | ... on BooleanBlock { 50 | valBool: value 51 | field 52 | } 53 | ... on StringBlock { 54 | valString: value 55 | field 56 | } 57 | ... on IntBlock { 58 | valInt: value 59 | field 60 | } 61 | ... on FloatBlock { 62 | valFloat: value 63 | field 64 | } 65 | ... on DateBlock { 66 | valDate: value 67 | field 68 | } 69 | ... on TimeBlock { 70 | valTime: value 71 | field 72 | } 73 | ... on DateTimeBlock { 74 | valDatetime: value 75 | field 76 | } 77 | } 78 | custom { 79 | __typename 80 | ... on Test_app_2CustomBlock1 { 81 | field 82 | fieldChar 83 | fieldText 84 | fieldEmail 85 | fieldInt 86 | fieldFloat 87 | fieldDecimal 88 | fieldRegex 89 | fieldUrl 90 | fieldBool 91 | fieldDate 92 | fieldTime 93 | fieldDatetime 94 | fieldRich 95 | fieldRaw 96 | fieldQuote 97 | fieldChoice 98 | fieldStatic 99 | fieldList 100 | } 101 | ... on Test_app_2CustomBlock2 { 102 | field 103 | fieldLink { 104 | title 105 | } 106 | fieldLinkList { 107 | title 108 | } 109 | fieldImage { 110 | title 111 | } 112 | fieldImageList { 113 | title 114 | width 115 | } 116 | fieldSnippet { 117 | text 118 | } 119 | fieldSnippetList { 120 | text 121 | } 122 | } 123 | } 124 | links { 125 | __typename 126 | ... on PageBlock { 127 | value { 128 | title 129 | id 130 | } 131 | field 132 | } 133 | ... on ImageBlock { 134 | value { 135 | title 136 | width 137 | } 138 | field 139 | } 140 | ... on Test_app_2App2SnippetBlock { 141 | value { 142 | text 143 | } 144 | field 145 | } 146 | } 147 | lists { 148 | __typename 149 | ... on StringListBlock { 150 | value 151 | field 152 | } 153 | ... on DateTimeListBlock { 154 | valDateTime: value 155 | field 156 | } 157 | ... on TimeListBlock { 158 | valTime: value 159 | field 160 | } 161 | ... on DateListBlock { 162 | valDate: value 163 | field 164 | } 165 | ... on FloatListBlock { 166 | valFloat: value 167 | field 168 | } 169 | ... on IntListBlock { 170 | valInt: value 171 | field 172 | } 173 | } 174 | 175 | linksList { 176 | __typename 177 | ... on ImageListBlock { 178 | value { 179 | title 180 | width 181 | } 182 | field 183 | } 184 | ... on PageListBlock { 185 | value { 186 | title 187 | } 188 | field 189 | } 190 | ... on Test_app_2App2SnippetListBlock { 191 | value { 192 | text 193 | } 194 | field 195 | } 196 | } 197 | 198 | customLists { 199 | __typename 200 | ... on Test_app_2CustomBlock1ListBlock { 201 | value { 202 | fieldChar 203 | fieldText 204 | fieldDecimal 205 | fieldDate 206 | fieldDatetime 207 | fieldTime 208 | } 209 | field 210 | } 211 | } 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /tests/graphql/test_app_2_streamfield_relay.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "page": { 4 | "__typename": "Test_app_2PageTypeA", 5 | "another": [ 6 | { 7 | "__typename": "StringBlock", 8 | "field": "h4", 9 | "val": "and H4" 10 | }, 11 | { 12 | "__typename": "IntBlock", 13 | "field": "n2", 14 | "value": 1000 15 | }, 16 | { 17 | "__typename": "StringBlock", 18 | "field": "h3", 19 | "val": "or H3" 20 | } 21 | ], 22 | "contentType": "test_app_2.PageTypeA", 23 | "custom": [ 24 | { 25 | "__typename": "Test_app_2CustomBlock1", 26 | "field": "custom1", 27 | "fieldBool": true, 28 | "fieldChar": "sdfsdh", 29 | "fieldChoice": "coffee", 30 | "fieldDate": "2019-01-02", 31 | "fieldDatetime": "2019-01-08T16:00:00+00:00", 32 | "fieldDecimal": "2323.23", 33 | "fieldEmail": "dsfsdf@sdsdf.com", 34 | "fieldFloat": 23324.21232, 35 | "fieldInt": 324324, 36 | "fieldList": [ 37 | "wwewer", 38 | "dsfsdsdf" 39 | ], 40 | "fieldQuote": "jhdsfjksdhfjkdshf", 41 | "fieldRaw": "test
", 42 | "fieldRegex": "234", 43 | "fieldRich": "ffdsdfgdfjg
this is a paragraph
" 347 | }, 348 | { 349 | "__typename": "StringBlock", 350 | "field": "quote", 351 | "valString": "quote test" 352 | }, 353 | { 354 | "__typename": "StringBlock", 355 | "field": "choice", 356 | "valString": "tea" 357 | }, 358 | { 359 | "__typename": "StringBlock", 360 | "field": "static", 361 | "valString": null 362 | } 363 | ], 364 | "title": "TEST", 365 | "urlPath": "/test" 366 | } 367 | } 368 | } -------------------------------------------------------------------------------- /tests/graphql/test_user_admin.graphql: -------------------------------------------------------------------------------- 1 | query { 2 | user { 3 | id 4 | isSuperuser 5 | username 6 | firstName 7 | lastName 8 | email 9 | isStaff 10 | isActive 11 | dateJoined 12 | } 13 | } -------------------------------------------------------------------------------- /tests/graphql/test_user_admin.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "user": { 4 | "dateJoined": "2019-01-04T23:51:26.589548+00:00", 5 | "email": "admin@admin.org", 6 | "firstName": "First", 7 | "id": "1", 8 | "isActive": true, 9 | "isStaff": true, 10 | "isSuperuser": true, 11 | "lastName": "Last", 12 | "username": "admin" 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /tests/graphql/test_user_anonymous.graphql: -------------------------------------------------------------------------------- 1 | query { 2 | user { 3 | id 4 | isSuperuser 5 | username 6 | firstName 7 | lastName 8 | email 9 | isStaff 10 | isActive 11 | } 12 | } -------------------------------------------------------------------------------- /tests/graphql/test_user_anonymous.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "user": { 4 | "id": "-1", 5 | "isSuperuser": false, 6 | "username": "anonymous", 7 | "firstName": "", 8 | "lastName": "", 9 | "email": "", 10 | "isStaff": false, 11 | "isActive": true 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .graphql import assert_query 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_get_user(client): 7 | assert_query(client, 'test_user', 'anonymous') 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_get_user_admin(client): 12 | client.post('/graphql', 13 | {"query": 'mutation { login(username: "admin", password: "password") { user { username } } }'} 14 | ) 15 | assert_query(client, 'test_user', 'admin') 16 | response = client.post( 17 | '/graphql', 18 | {"query": 'mutation { logout { user { username } } }'} 19 | ) 20 | assert response.json() == {'data': {'logout': {'user': {'username': 'anonymous'}}}} 21 | 22 | 23 | @pytest.mark.django_db 24 | def test_get_user_admin_fail(client): 25 | client.post('/graphql', 26 | {"query": 'mutation { login(username: "admin", password: "fail") { user { username } } }'} 27 | ) 28 | assert_query(client, 'test_user', 'anonymous') 29 | -------------------------------------------------------------------------------- /tests/test_basic_pages.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .graphql import assert_query, assert_query_fail 3 | from django.conf import settings 4 | IS_RELAY = settings.GRAPHQL_API.get('RELAY', False) 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_type(client): 9 | response = client.post('/graphql', {"query": "{__schema { types { name kind } } }"}) 10 | assert response.status_code == 200 11 | assert {'name': 'Test_app_1HomePage', 'kind': 'OBJECT'} in response.json()['data']['__schema']['types'] 12 | 13 | 14 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 15 | @pytest.mark.django_db 16 | def test_get_page(client): 17 | assert_query(client, 'test_app_1', 'get_home') 18 | 19 | 20 | @pytest.mark.skipif(not IS_RELAY, reason="requires the relay setting to be off") 21 | @pytest.mark.django_db 22 | def test_get_page_relay(client): 23 | assert_query(client, 'test_app_1', 'get_home', 'relay') 24 | 25 | 26 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 27 | @pytest.mark.django_db 28 | def test_get_page_latest(client): 29 | assert_query(client, 'test_app_1', 'get_home', 'latest') 30 | 31 | 32 | @pytest.mark.skipif(not IS_RELAY, reason="requires the relay setting to be off") 33 | @pytest.mark.django_db 34 | def test_get_page_latest_relay(client): 35 | assert_query(client, 'test_app_1', 'get_home', 'latest', 'relay') 36 | 37 | 38 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 39 | @pytest.mark.django_db 40 | def test_get_page_revision(client): 41 | assert_query(client, 'test_app_1', 'get_home', 'revision') 42 | 43 | 44 | @pytest.mark.skipif(not IS_RELAY, reason="requires the relay setting to be off") 45 | @pytest.mark.django_db 46 | def test_get_page_revision_relay(client): 47 | assert_query(client, 'test_app_1', 'get_home', 'revision', 'relay') 48 | 49 | 50 | @pytest.mark.django_db 51 | def test_get_page_doesnt_exist(client): 52 | assert_query(client, 'test_app_1', 'get_home', 'none') 53 | 54 | 55 | @pytest.mark.django_db 56 | def test_get_page_revision_doesnt_exist(client): 57 | assert_query_fail(client, 'test_app_1', 'get_home', 'revision', 'fail') 58 | 59 | 60 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 61 | @pytest.mark.django_db 62 | def test_get_pages(client): 63 | assert_query(client, 'test_app_1', 'get', 'pages') 64 | 65 | 66 | @pytest.mark.skipif(not IS_RELAY, reason="requires the relay setting to be off") 67 | @pytest.mark.django_db 68 | def test_get_pages_relay(client): 69 | assert_query(client, 'test_app_1', 'get', 'pages', 'relay') 70 | 71 | 72 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 73 | @pytest.mark.django_db 74 | def test_get_pages_parent(client): 75 | assert_query(client, 'test_app_1', 'get', 'pages', 'parent') 76 | 77 | 78 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 79 | @pytest.mark.django_db 80 | def test_get_pages_parent_fail(client): 81 | assert_query_fail(client, 'test_app_1', 'get', 'pages', 'parent', 'fail') 82 | 83 | 84 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 85 | @pytest.mark.django_db 86 | def test_get_children(client): 87 | assert_query(client, 'children') 88 | 89 | 90 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 91 | @pytest.mark.django_db 92 | def test_show_in_menus(client): 93 | assert_query(client, 'showmenus') 94 | 95 | 96 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 97 | @pytest.mark.django_db 98 | def test_prefetch(client): 99 | assert_query(client, 'prefetch') 100 | -------------------------------------------------------------------------------- /tests/test_documents.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .graphql import assert_query 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_documents(client): 7 | assert_query(client, 'documents', 'all') 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_document_1(client): 12 | assert_query(client, 'document', '1') 13 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_form_1(client): 7 | query = """ 8 | mutation($values: GenericScalar) { 9 | testApp1FormPage(url: "/form", values: $values) { 10 | result 11 | errors { 12 | name 13 | errors 14 | } 15 | } 16 | } 17 | """ 18 | variables = json.dumps({ 19 | "values": {"label-1": "1", "other-label": 2} 20 | }) 21 | 22 | response = client.post('/graphql', {"query": query, "variables": variables}) 23 | print(response) 24 | print(response.json()) 25 | assert response.status_code == 200 26 | response_json = response.json() 27 | assert response_json['data']['testApp1FormPage']['errors'] is None 28 | assert response_json['data']['testApp1FormPage']['result'] == 'OK' 29 | 30 | 31 | @pytest.mark.django_db 32 | def test_form_fail_1(client): 33 | query = """ 34 | mutation($values: GenericScalar) { 35 | testApp1FormPage(url: "/form", values: $values) { 36 | result 37 | errors { 38 | name 39 | errors 40 | } 41 | } 42 | } 43 | """ 44 | variables = json.dumps({ 45 | "values": {"label-fail": "1", "other-label": 2} 46 | }) 47 | 48 | response = client.post('/graphql', {"query": query, "variables": variables}) 49 | print(response) 50 | print(response.json()) 51 | assert response.status_code == 200 52 | response_json = response.json() 53 | assert response_json['data']['testApp1FormPage']['errors'] is not None 54 | assert {'name': 'label-1', 'errors': ['This field is required.']}\ 55 | in response_json['data']['testApp1FormPage']['errors'] 56 | assert response_json['data']['testApp1FormPage']['result'] == 'FAIL' 57 | 58 | 59 | @pytest.mark.django_db 60 | def test_form_fields(client): 61 | query = """ 62 | query { 63 | page(id: 6) { 64 | ... on Test_app_1FormPage { 65 | formFields { 66 | name 67 | fieldType 68 | helpText 69 | required 70 | choices 71 | defaultValue 72 | label 73 | } 74 | } 75 | } 76 | } 77 | """ 78 | response = client.post('/graphql', {"query": query}) 79 | print(response) 80 | print(response.json()) 81 | 82 | assert response.json() == { 83 | "data": { 84 | "page": { 85 | "formFields": [ 86 | { 87 | "choices": "", 88 | "defaultValue": "default", 89 | "fieldType": "singleline", 90 | "helpText": "Help", 91 | "label": "Label 1", 92 | "name": "label-1", 93 | "required": True 94 | }, 95 | { 96 | "choices": "", 97 | "defaultValue": "1", 98 | "fieldType": "number", 99 | "helpText": "", 100 | "label": "other label", 101 | "name": "other-label", 102 | "required": False 103 | } 104 | ] 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/test_images.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .graphql import assert_query 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_image(client): 7 | assert_query(client, 'image', '1') 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_image_with_focal_point(client): 12 | assert_query(client, 'image', '2') 13 | 14 | 15 | @pytest.mark.django_db 16 | def test_image_with_rendition(client): 17 | assert_query(client, 'image', '1', 'rendition') 18 | 19 | 20 | @pytest.mark.django_db 21 | def test_images_list(client): 22 | assert_query(client, 'images', 'all') 23 | -------------------------------------------------------------------------------- /tests/test_menus.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .graphql import assert_query 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_documents(client): 7 | assert_query(client, 'menus', 'all') 8 | -------------------------------------------------------------------------------- /tests/test_permissions.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tr11/wagtail-graphql/1c10172f2fb11a5568a8099de00ed2afb59340d7/tests/test_permissions.py -------------------------------------------------------------------------------- /tests/test_project/.gitignore: -------------------------------------------------------------------------------- 1 | media 2 | migrations 3 | -------------------------------------------------------------------------------- /tests/test_project/db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tr11/wagtail-graphql/1c10172f2fb11a5568a8099de00ed2afb59340d7/tests/test_project/db.sqlite3 -------------------------------------------------------------------------------- /tests/test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings.test") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /tests/test_project/project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tr11/wagtail-graphql/1c10172f2fb11a5568a8099de00ed2afb59340d7/tests/test_project/project/__init__.py -------------------------------------------------------------------------------- /tests/test_project/project/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tr11/wagtail-graphql/1c10172f2fb11a5568a8099de00ed2afb59340d7/tests/test_project/project/settings/__init__.py -------------------------------------------------------------------------------- /tests/test_project/project/settings/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for project project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.1/ref/settings/ 11 | """ 12 | 13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 14 | import os 15 | 16 | PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | BASE_DIR = os.path.dirname(PROJECT_DIR) 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 22 | 23 | 24 | # Application definition 25 | 26 | INSTALLED_APPS = [ 27 | 'test_app_1', 28 | 'test_app_2', 29 | 30 | 'wagtail_graphql', 31 | 'graphene_django', 32 | 33 | 'wagtail.contrib.modeladmin', 34 | 'wagtailmenus', 35 | 36 | 'wagtail.contrib.settings', 37 | 38 | 'wagtail.contrib.forms', 39 | 'wagtail.contrib.redirects', 40 | 'wagtail.embeds', 41 | 'wagtail.sites', 42 | 'wagtail.users', 43 | 'wagtail.snippets', 44 | 'wagtail.documents', 45 | 'wagtail.images', 46 | 'wagtail.search', 47 | 'wagtail.admin', 48 | 'wagtail.core', 49 | 50 | 'corsheaders', 51 | 'modelcluster', 52 | 'taggit', 53 | 54 | 'django.contrib.admin', 55 | 'django.contrib.auth', 56 | 'django.contrib.contenttypes', 57 | 'django.contrib.sessions', 58 | 'django.contrib.messages', 59 | 'django.contrib.staticfiles', 60 | ] 61 | 62 | MIDDLEWARE = [ 63 | 'corsheaders.middleware.CorsMiddleware', 64 | 'django.contrib.sessions.middleware.SessionMiddleware', 65 | 'django.middleware.common.CommonMiddleware', 66 | 'django.middleware.csrf.CsrfViewMiddleware', 67 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 68 | 'django.contrib.messages.middleware.MessageMiddleware', 69 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 70 | 'django.middleware.security.SecurityMiddleware', 71 | 72 | 'wagtail.core.middleware.SiteMiddleware', 73 | 'wagtail.contrib.redirects.middleware.RedirectMiddleware', 74 | ] 75 | 76 | ROOT_URLCONF = 'project.urls' 77 | 78 | TEMPLATES = [ 79 | { 80 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 81 | 'DIRS': [ 82 | os.path.join(PROJECT_DIR, 'templates'), 83 | ], 84 | 'APP_DIRS': True, 85 | 'OPTIONS': { 86 | 'context_processors': [ 87 | 'django.template.context_processors.debug', 88 | 'django.template.context_processors.request', 89 | 'django.contrib.auth.context_processors.auth', 90 | 'django.contrib.messages.context_processors.messages', 91 | ], 92 | }, 93 | }, 94 | ] 95 | 96 | WSGI_APPLICATION = 'project.wsgi.application' 97 | 98 | 99 | # Database 100 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 101 | DATABASES = { 102 | 'default': { 103 | 'ENGINE': 'django.db.backends.sqlite3', 104 | 'NAME': os.path.join(os.path.join(os.path.dirname(__file__), '..', '..'), 'db.sqlite3'), 105 | } 106 | } 107 | 108 | 109 | # Password validation 110 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 111 | 112 | AUTH_PASSWORD_VALIDATORS = [ 113 | { 114 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 115 | }, 116 | { 117 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 118 | }, 119 | { 120 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 121 | }, 122 | { 123 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 124 | }, 125 | ] 126 | 127 | 128 | # Internationalization 129 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 130 | 131 | LANGUAGE_CODE = 'en-us' 132 | 133 | TIME_ZONE = 'UTC' 134 | 135 | USE_I18N = True 136 | 137 | USE_L10N = True 138 | 139 | USE_TZ = True 140 | 141 | 142 | # Static files (CSS, JavaScript, Images) 143 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 144 | 145 | STATICFILES_FINDERS = [ 146 | 'django.contrib.staticfiles.finders.FileSystemFinder', 147 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 148 | ] 149 | 150 | STATICFILES_DIRS = [ 151 | os.path.join(PROJECT_DIR, 'static'), 152 | ] 153 | 154 | # ManifestStaticFilesStorage is recommended in production, to prevent outdated 155 | # Javascript / CSS assets being served from cache (e.g. after a Wagtail upgrade). 156 | # See https://docs.djangoproject.com/en/2.1/ref/contrib/staticfiles/#manifeststaticfilesstorage 157 | STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' 158 | 159 | STATIC_ROOT = os.path.join(BASE_DIR, 'static') 160 | STATIC_URL = '/static/' 161 | 162 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media') 163 | MEDIA_URL = '/media/' 164 | 165 | 166 | # Wagtail settings 167 | 168 | WAGTAIL_SITE_NAME = "project" 169 | 170 | # Base URL to use when referring to full URLs within the Wagtail admin backend - 171 | # e.g. in notification emails. Don't include '/admin' or a trailing slash 172 | BASE_URL = 'http://example.com' 173 | 174 | 175 | GRAPHENE = { 176 | 'SCHEMA': 'wagtail_graphql.schema.schema', 177 | } 178 | 179 | GRAPHQL_API = { 180 | 'APPS': [ 181 | 'test_app_1', 182 | 'test_app_2', 183 | ], 184 | 'PREFIX': { 185 | }, 186 | 'URL_PREFIX': { 187 | 188 | }, 189 | 'RELAY': False, 190 | } 191 | -------------------------------------------------------------------------------- /tests/test_project/project/settings/relay.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | # SECURITY WARNING: don't run with debug turned on in production! 4 | DEBUG = True 5 | CORS_ORIGIN_ALLOW_ALL = DEBUG 6 | 7 | # SECURITY WARNING: keep the secret key used in production secret! 8 | SECRET_KEY = 'l_0ybxo5&rg-b&5-ya$a5v-kks7&znfl)b52@n9@5qt@to$b%j' 9 | 10 | # SECURITY WARNING: define the correct hosts in production! 11 | ALLOWED_HOSTS = ['*'] 12 | 13 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 14 | 15 | GRAPHQL_API['RELAY'] = True 16 | -------------------------------------------------------------------------------- /tests/test_project/project/settings/test.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | # SECURITY WARNING: don't run with debug turned on in production! 4 | DEBUG = True 5 | CORS_ORIGIN_ALLOW_ALL = DEBUG 6 | 7 | # SECURITY WARNING: keep the secret key used in production secret! 8 | SECRET_KEY = 'l_0ybxo5&rg-b&5-ya$a5v-kks7&znfl)b52@n9@5qt@to$b%j' 9 | 10 | # SECURITY WARNING: define the correct hosts in production! 11 | ALLOWED_HOSTS = ['*'] 12 | 13 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/test_project/project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls import include, url 3 | from django.contrib import admin 4 | 5 | from wagtail.admin import urls as wagtailadmin_urls 6 | from wagtail.core import urls as wagtail_urls 7 | from wagtail.documents import urls as wagtaildocs_urls 8 | 9 | from django.views.decorators.csrf import csrf_exempt 10 | from graphene_django.views import GraphQLView 11 | from wagtail.images.views.serve import ServeView 12 | 13 | 14 | class CatchErrorsMiddleware(object): 15 | 16 | def on_error(self, error): 17 | import traceback 18 | traceback.print_tb(error.__traceback__) 19 | raise error 20 | 21 | def resolve(self, next, root, info, **args): 22 | return next(root, info, **args).catch(self.on_error) 23 | 24 | 25 | 26 | urlpatterns = [ 27 | url(r'^django-admin/', admin.site.urls), 28 | url(r'^admin/', include(wagtailadmin_urls)), 29 | url(r'^documents/', include(wagtaildocs_urls)), 30 | url(r'^graphql', csrf_exempt(GraphQLView.as_view())), 31 | 32 | url(r'^graphql', csrf_exempt(GraphQLView.as_view())), 33 | url(r'^graphiql', csrf_exempt(GraphQLView.as_view(graphiql=True, pretty=True, middleware=[CatchErrorsMiddleware]))), 34 | 35 | url(r'^images/([^/]*)/(\d*)/([^/]*)/[^/]*$', ServeView.as_view(), name='wagtailimages_serve'), 36 | 37 | # For anything not caught by a more specific rule above, hand over to 38 | # Wagtail's page serving mechanism. This should be the last pattern in 39 | # the list: 40 | url(r'', include(wagtail_urls)), 41 | 42 | # Alternatively, if you want Wagtail pages to be served from a subpath 43 | # of your site, rather than the site root: 44 | # url(r'^pages/', include(wagtail_urls)), 45 | ] 46 | 47 | 48 | if settings.DEBUG: 49 | from django.conf.urls.static import static 50 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 51 | 52 | # Serve static and media files from development server 53 | urlpatterns += staticfiles_urlpatterns() 54 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 55 | -------------------------------------------------------------------------------- /tests/test_project/project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings.dev") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/test_project/test_app_1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tr11/wagtail-graphql/1c10172f2fb11a5568a8099de00ed2afb59340d7/tests/test_project/test_app_1/__init__.py -------------------------------------------------------------------------------- /tests/test_project/test_app_1/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('wagtailcore', '0040_page_draft_title'), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name='HomePage', 14 | fields=[ 15 | ('page_ptr', models.OneToOneField(on_delete=models.CASCADE, parent_link=True, auto_created=True, primary_key=True, serialize=False, to='wagtailcore.Page')), 16 | ], 17 | options={ 18 | 'abstract': False, 19 | }, 20 | bases=('wagtailcore.page',), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /tests/test_project/test_app_1/migrations/0002_create_homepage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import migrations 3 | 4 | 5 | def create_homepage(apps, schema_editor): 6 | # Get models 7 | ContentType = apps.get_model('contenttypes.ContentType') 8 | Page = apps.get_model('wagtailcore.Page') 9 | Site = apps.get_model('wagtailcore.Site') 10 | HomePage = apps.get_model('test_app_1.HomePage') 11 | 12 | # Delete the default homepage 13 | # If migration is run multiple times, it may have already been deleted 14 | Page.objects.filter(id=2).delete() 15 | 16 | # Create content type for homepage model 17 | homepage_content_type, __ = ContentType.objects.get_or_create( 18 | model='homepage', app_label='test_app_1') 19 | 20 | # Create a new homepage 21 | homepage = HomePage.objects.create( 22 | title="Home", 23 | draft_title="Home", 24 | slug='home', 25 | content_type=homepage_content_type, 26 | path='00010001', 27 | depth=2, 28 | numchild=0, 29 | url_path='/home/', 30 | ) 31 | 32 | # Create a site with the new homepage set as the root 33 | Site.objects.create( 34 | hostname='localhost', root_page=homepage, is_default_site=True) 35 | 36 | 37 | def remove_homepage(apps, schema_editor): 38 | # Get models 39 | ContentType = apps.get_model('contenttypes.ContentType') 40 | HomePage = apps.get_model('test_app_1.HomePage') 41 | 42 | # Delete the default homepage 43 | # Page and Site objects CASCADE 44 | HomePage.objects.filter(slug='home', depth=2).delete() 45 | 46 | # Delete content type for homepage model 47 | ContentType.objects.filter(model='homepage', app_label='test_app_1').delete() 48 | 49 | 50 | class Migration(migrations.Migration): 51 | 52 | dependencies = [ 53 | ('test_app_1', '0001_initial'), 54 | ] 55 | 56 | operations = [ 57 | migrations.RunPython(create_homepage, remove_homepage), 58 | ] 59 | -------------------------------------------------------------------------------- /tests/test_project/test_app_1/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tr11/wagtail-graphql/1c10172f2fb11a5568a8099de00ed2afb59340d7/tests/test_project/test_app_1/migrations/__init__.py -------------------------------------------------------------------------------- /tests/test_project/test_app_1/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from modelcluster.fields import ParentalKey 3 | from wagtail.admin.edit_handlers import FieldPanel, InlinePanel 4 | from wagtail.contrib.forms.models import AbstractForm, AbstractFormField 5 | 6 | from wagtail.core.models import Page 7 | from wagtail.snippets.models import register_snippet 8 | 9 | 10 | class HomePage(Page): 11 | field_char = models.CharField(max_length=10, null=True, blank=True) 12 | field_int = models.IntegerField(null=True, blank=True) 13 | field_bool = models.BooleanField(default=False) 14 | field_date = models.DateField(null=True, blank=True) 15 | field_datetime = models.DateTimeField(null=True, blank=True) 16 | field_url = models.URLField(null=True, blank=True) 17 | field_decimal = models.DecimalField(null=True, decimal_places=2, max_digits=5, blank=True) 18 | field_email = models.EmailField(null=True, blank=True) 19 | field_float = models.FloatField(null=True, blank=True) 20 | field_duration = models.DurationField(null=True, blank=True) 21 | field_intp = models.PositiveIntegerField(null=True, blank=True) 22 | field_smallintp = models.PositiveSmallIntegerField(null=True, blank=True) 23 | field_smallint = models.SmallIntegerField(null=True, blank=True) 24 | field_text = models.TextField(null=True, blank=True) 25 | field_time = models.TimeField(null=True, blank=True) 26 | field_ip = models.GenericIPAddressField(null=True, blank=True) 27 | field_uuid = models.UUIDField(null=True, blank=True) 28 | 29 | content_panels = Page.content_panels + [ 30 | FieldPanel('field_char'), 31 | FieldPanel('field_int'), 32 | FieldPanel('field_bool'), 33 | FieldPanel('field_date'), 34 | FieldPanel('field_datetime'), 35 | FieldPanel('field_url'), 36 | FieldPanel('field_decimal'), 37 | FieldPanel('field_email'), 38 | FieldPanel('field_float'), 39 | FieldPanel('field_duration'), 40 | FieldPanel('field_intp'), 41 | FieldPanel('field_smallintp'), 42 | FieldPanel('field_smallint'), 43 | FieldPanel('field_text'), 44 | FieldPanel('field_time'), 45 | FieldPanel('field_ip'), 46 | FieldPanel('field_uuid'), 47 | ] 48 | 49 | 50 | @register_snippet 51 | class Advert(models.Model): 52 | url = models.URLField(null=True, blank=True) 53 | text = models.CharField(max_length=255) 54 | 55 | panels = [ 56 | FieldPanel('url'), 57 | FieldPanel('text'), 58 | ] 59 | 60 | def __str__(self): 61 | return self.text 62 | 63 | 64 | class FormField(AbstractFormField): 65 | page = ParentalKey('FormPage', on_delete=models.CASCADE, related_name='form_fields') 66 | 67 | 68 | class FormPage(AbstractForm): 69 | body = models.TextField() 70 | thank_you_text = models.TextField(blank=True) 71 | 72 | content_panels = AbstractForm.content_panels + [ 73 | FieldPanel('body'), 74 | FieldPanel('thank_you_text', classname="full"), 75 | InlinePanel('form_fields', label="Form fields"), 76 | ] 77 | -------------------------------------------------------------------------------- /tests/test_project/test_app_1/templates/home/home_page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body_class %}template-homepage{% endblock %} 4 | 5 | {% block content %} 6 |You can access the admin interface here (make sure you have run "./manage.py createsuperuser" in the console first).
9 | 10 |If you haven't already given the documentation a read, head over to http://docs.wagtail.io to start building on Wagtail
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /tests/test_project/test_app_2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tr11/wagtail-graphql/1c10172f2fb11a5568a8099de00ed2afb59340d7/tests/test_project/test_app_2/__init__.py -------------------------------------------------------------------------------- /tests/test_project/test_app_2/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /tests/test_project/test_app_2/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestApp2Config(AppConfig): 5 | name = 'test_app_2' 6 | -------------------------------------------------------------------------------- /tests/test_project/test_app_2/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from wagtail.core.fields import StreamField 3 | from wagtail.core.models import Page 4 | from wagtail.core import blocks 5 | from wagtail.admin.edit_handlers import FieldPanel, StreamFieldPanel 6 | from wagtail.images.blocks import ImageChooserBlock 7 | from wagtail.core.blocks import PageChooserBlock 8 | from wagtail.snippets.blocks import SnippetChooserBlock 9 | from wagtail.snippets.models import register_snippet 10 | from wagtail.contrib.settings.models import BaseSetting, register_setting 11 | 12 | 13 | @register_setting 14 | class SiteBranding(BaseSetting): 15 | site_setting1 = models.TextField() 16 | site_setting2 = models.TextField() 17 | 18 | panels = [ 19 | FieldPanel('site_setting1'), 20 | FieldPanel('site_setting2'), 21 | ] 22 | 23 | 24 | @register_setting 25 | class AnotherSetting(BaseSetting): 26 | name = models.TextField() 27 | setting = models.TextField() 28 | 29 | panels = [ 30 | FieldPanel('name'), 31 | FieldPanel('setting'), 32 | ] 33 | 34 | 35 | @register_snippet 36 | class App2Snippet(models.Model): 37 | text = models.CharField(max_length=255) 38 | 39 | panels = [ 40 | FieldPanel('text'), 41 | ] 42 | 43 | def __str__(self): 44 | return self.text 45 | 46 | 47 | class CustomBlockInner(blocks.StructBlock): 48 | field_text = blocks.TextBlock(required=False) 49 | 50 | 51 | class CustomBlock1(blocks.StructBlock): 52 | field_char = blocks.CharBlock(required=False) 53 | field_text = blocks.TextBlock(required=False) 54 | field_email = blocks.EmailBlock(required=False) 55 | field_int = blocks.IntegerBlock(required=False) 56 | field_float = blocks.FloatBlock(required=False) 57 | field_decimal = blocks.DecimalBlock(required=False) 58 | field_regex = blocks.RegexBlock(regex=r'^[0-9]{3}$', required=False) 59 | field_url = blocks.URLBlock(required=False) 60 | field_bool = blocks.BooleanBlock(required=False) 61 | field_date = blocks.DateBlock(required=False) 62 | field_time = blocks.TimeBlock(required=False) 63 | field_datetime = blocks.DateTimeBlock(required=False) 64 | field_rich = blocks.RichTextBlock(required=False) 65 | field_raw = blocks.RawHTMLBlock(required=False) 66 | field_quote = blocks.BlockQuoteBlock(required=False) 67 | field_choice = blocks.ChoiceBlock(choices=[('tea', 'Tea'), ('coffee', 'Coffee')], icon='cup', required=False) 68 | field_static = blocks.StaticBlock(required=False) 69 | field_list = blocks.ListBlock(blocks.CharBlock, required=False) 70 | field_list_2 = blocks.ListBlock(CustomBlockInner, required=False) 71 | 72 | 73 | class CustomBlock2(blocks.StructBlock): 74 | field_link = PageChooserBlock(required=False) 75 | field_link_list = blocks.ListBlock(PageChooserBlock(), required=False) 76 | field_image = ImageChooserBlock(required=False) 77 | field_image_list = blocks.ListBlock(ImageChooserBlock(), required=False) 78 | field_snippet = SnippetChooserBlock(target_model=App2Snippet, required=False) 79 | field_snippet_list = blocks.ListBlock(SnippetChooserBlock(target_model=App2Snippet), required=False) 80 | 81 | 82 | class PageTypeA(Page): 83 | streamfield = StreamField([ 84 | ('h1', blocks.CharBlock(icon="title", classname="title")), 85 | ('h2', blocks.CharBlock(icon="subtitle", classname="subtitle")), 86 | ('n1', blocks.IntegerBlock(icon="subtitle", classname="subtitle")), 87 | ], null=True, blank=True) 88 | 89 | another = StreamField([ 90 | ('h3', blocks.CharBlock(icon="title", classname="title")), 91 | ('h4', blocks.CharBlock(icon="subtitle", classname="subtitle")), 92 | ('n2', blocks.IntegerBlock(icon="subtitle", classname="subtitle")), 93 | ], null=True, blank=True) 94 | 95 | third = StreamField([ 96 | ('char', blocks.CharBlock()), 97 | ('text', blocks.TextBlock()), 98 | ('email', blocks.EmailBlock()), 99 | ('int', blocks.IntegerBlock()), 100 | ('float', blocks.FloatBlock()), 101 | ('decimal', blocks.DecimalBlock()), 102 | ('regex', blocks.RegexBlock(regex=r'^[0-9]{3}$')), 103 | ('url', blocks.URLBlock()), 104 | ('bool', blocks.BooleanBlock()), 105 | ('date', blocks.DateBlock()), 106 | ('time', blocks.TimeBlock()), 107 | ('datetime', blocks.DateTimeBlock()), 108 | ('rich', blocks.RichTextBlock()), 109 | ('raw', blocks.RawHTMLBlock()), 110 | ('quote', blocks.BlockQuoteBlock()), 111 | ('choice', blocks.ChoiceBlock(choices=[('tea', 'Tea'), ('coffee', 'Coffee')], icon='cup')), 112 | ('static', blocks.StaticBlock()), 113 | ], null=True) 114 | 115 | links = StreamField([ 116 | ('image', ImageChooserBlock()), 117 | ('page', PageChooserBlock()), 118 | ('snippet', SnippetChooserBlock(target_model=App2Snippet)), 119 | ], null=True) 120 | 121 | lists = StreamField([ 122 | ('char', blocks.ListBlock(blocks.CharBlock())), 123 | ('text', blocks.ListBlock(blocks.TextBlock())), 124 | ('int', blocks.ListBlock(blocks.IntegerBlock())), 125 | ('float', blocks.ListBlock(blocks.FloatBlock())), 126 | ('decimal', blocks.ListBlock(blocks.DecimalBlock())), 127 | ('date', blocks.ListBlock(blocks.DateBlock())), 128 | ('time', blocks.ListBlock(blocks.TimeBlock())), 129 | ('datetime', blocks.ListBlock(blocks.DateTimeBlock())), 130 | ], null=True) 131 | 132 | links_list = StreamField([ 133 | ('image', blocks.ListBlock(ImageChooserBlock())), 134 | ('page', blocks.ListBlock(PageChooserBlock())), 135 | ('snippet', blocks.ListBlock(SnippetChooserBlock(target_model=App2Snippet))), 136 | ], null=True) 137 | 138 | custom = StreamField([ 139 | ('custom1', CustomBlock1()), 140 | ('custom2', CustomBlock2()), 141 | ], null=True) 142 | 143 | another_custom = StreamField([ 144 | ('custom1', CustomBlock1()), 145 | ('custom2', CustomBlock2()), 146 | ], null=True) 147 | 148 | custom_lists = StreamField([ 149 | ('custom1', blocks.ListBlock(CustomBlock1())), 150 | ('custom2', blocks.ListBlock(CustomBlock2())), 151 | ], null=True) 152 | 153 | content_panels = [ 154 | FieldPanel('title', classname="full title"), 155 | StreamFieldPanel('streamfield'), 156 | StreamFieldPanel('another'), 157 | StreamFieldPanel('third'), 158 | StreamFieldPanel('links'), 159 | StreamFieldPanel('custom'), 160 | StreamFieldPanel('another_custom'), 161 | StreamFieldPanel('lists'), 162 | StreamFieldPanel('links_list'), 163 | StreamFieldPanel('custom_lists'), 164 | ] 165 | 166 | 167 | -------------------------------------------------------------------------------- /tests/test_project/test_app_2/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /tests/test_project/test_app_2/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /tests/test_registry.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.django_db 5 | def test_reverse_models(client): 6 | from wagtail_graphql.registry import registry 7 | 8 | # force loading the api 9 | client.post('/graphql', {"query": "{ format }"}) 10 | 11 | models = registry.models 12 | assert {v: k for k, v in models.items()} == registry.rmodels 13 | 14 | 15 | @pytest.mark.django_db 16 | def test_reverse_snippets(client): 17 | from wagtail_graphql.registry import registry 18 | 19 | # force loading the api 20 | client.post('/graphql', {"query": "{ format }"}) 21 | 22 | snippets = registry.snippets 23 | assert {v: k for k, v in snippets.items()} == registry.rsnippets 24 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .graphql import assert_query, assert_query_fail 3 | from django.conf import settings 4 | IS_RELAY = settings.GRAPHQL_API.get('RELAY', False) 5 | 6 | 7 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 8 | @pytest.mark.django_db 9 | def test_settings(client): 10 | assert_query(client, 'settings') 11 | 12 | 13 | @pytest.mark.skipif(not IS_RELAY, reason="requires the relay setting to be off") 14 | @pytest.mark.django_db 15 | def test_settings_relay(client): 16 | assert_query(client, 'settings', 'relay') 17 | 18 | 19 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 20 | @pytest.mark.django_db 21 | def test_settings_fail(client): 22 | assert_query_fail(client, 'settings', 'fail') 23 | -------------------------------------------------------------------------------- /tests/test_site.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .graphql import assert_query 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_site(client): 7 | client.post('/graphql', 8 | {"query": 'mutation { login(username: "admin", password: "password") { user { username } } }'} 9 | ) 10 | assert_query(client, 'site') 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_site_fail(client): 15 | response = client.post('/graphql', {"query": 'query {root { id } }'}) 16 | assert response.status_code == 200 17 | assert {'data': {'root': None}} == response.json() 18 | -------------------------------------------------------------------------------- /tests/test_snippets.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .graphql import assert_query 3 | from django.conf import settings 4 | IS_RELAY = settings.GRAPHQL_API.get('RELAY', False) 5 | 6 | 7 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 8 | @pytest.mark.django_db 9 | def test_snippets(client): 10 | assert_query(client, 'snippets', '1') 11 | 12 | 13 | @pytest.mark.skipif(not IS_RELAY, reason="requires the relay setting to be off") 14 | @pytest.mark.django_db 15 | def test_snippets_relay(client): 16 | assert_query(client, 'snippets', '1', 'relay') 17 | -------------------------------------------------------------------------------- /tests/test_streamfield.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .graphql import assert_query 3 | from django.conf import settings 4 | IS_RELAY = settings.GRAPHQL_API.get('RELAY', False) 5 | 6 | 7 | @pytest.mark.skipif(IS_RELAY, reason="requires the relay setting") 8 | @pytest.mark.django_db 9 | def test_streamfield_scalar_blocks(client): 10 | assert_query(client, 'test_app_2', 'streamfield') 11 | 12 | 13 | @pytest.mark.skipif(not IS_RELAY, reason="requires the relay setting to be off") 14 | @pytest.mark.django_db 15 | def test_streamfield_scalar_blocks_relay(client): 16 | assert_query(client, 'test_app_2', 'streamfield', 'relay') 17 | -------------------------------------------------------------------------------- /tests/test_wagtail_graphql.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from wagtail_graphql import __version__ 3 | from django.conf import settings 4 | IS_RELAY = settings.GRAPHQL_API.get('RELAY', False) 5 | 6 | EXPECTED_VERSION = '0.2.0' 7 | EXPECTED_API_VERSION = '0.2.0' 8 | 9 | 10 | def test_version_import(): 11 | assert __version__ == EXPECTED_VERSION 12 | 13 | 14 | @pytest.mark.django_db 15 | def test_version_api(client): 16 | response = client.post('/graphql', {"query": "{ format }"}) 17 | assert response.status_code == 200 18 | assert response.json() == {'data': {'format': EXPECTED_API_VERSION}} 19 | 20 | 21 | @pytest.mark.django_db 22 | def test_types(client): 23 | response = client.post('/graphql', {"query": "{__schema { types { name } } }"}) 24 | assert response.status_code == 200 25 | expected = { 26 | 'Query', 27 | 'Int', 28 | 'String', 29 | 'Boolean', 30 | 'User', 31 | 'ID', 32 | 'DateTime', 33 | 'Document', 34 | 'Image', 35 | 'Rect', 36 | 'Site', 37 | 'Test_app_1HomePage', 38 | 'Mutation', 39 | '__Schema', 40 | '__Type', 41 | '__TypeKind', 42 | '__Field', 43 | '__InputValue', 44 | '__EnumValue', 45 | '__Directive', 46 | '__DirectiveLocation', 47 | 'Date', 48 | 'Float', 49 | 'BasePage', 50 | 'Page', 51 | 'LoginMutation', 52 | 'LogoutMutation', 53 | 'Test_app_2PageTypeAAnotherType', 54 | 'Test_app_2PageTypeA', 55 | 'StringBlock', 56 | 'Test_app_2PageTypeAStreamfieldType', 57 | 'IntBlock', 58 | 'Time', 59 | 'GenericScalar', 60 | 'Test_app_2PageTypeAThirdType', 61 | 'DateBlock', 62 | 'GenericScalarBlock', 63 | 'BooleanBlock', 64 | 'UUID', 65 | 'FloatBlock', 66 | 'DateTimeBlock', 67 | 'TimeBlock', 68 | 'Snippet', 69 | 'Test_app_1Advert', 70 | 'MainMenuMaxLevels', 71 | 'FlatMenuMaxLevels', 72 | 'SecondaryMenuItem', 73 | 'MenuItem', 74 | 'Menu', 75 | 'SecondaryMenu', 76 | 'Test_app_2PageTypeAAnotherCustomType', 77 | 'Test_app_2PageTypeACustomType', 78 | 'PageBlock', 79 | 'Test_app_2CustomBlockInner', 80 | 'Test_app_2PageTypeALinksType', 81 | 'Test_app_2CustomBlock1', 82 | 'Test_app_2CustomBlock2', 83 | 'Test_app_2App2Snippet', 84 | 'Test_app_2App2SnippetBlock', 85 | 'ImageBlock', 86 | 'DateTimeListBlock', 87 | 'Test_app_2App2SnippetListBlock', 88 | 'Test_app_2PageTypeALinksListType', 89 | 'Test_app_2PageTypeAListsType', 90 | 'Test_app_2CustomBlock2ListBlock', 91 | 'Test_app_2CustomBlock1ListBlock', 92 | 'FloatListBlock', 93 | 'TimeListBlock', 94 | 'DateListBlock', 95 | 'PageListBlock', 96 | 'ImageListBlock', 97 | 'Test_app_2PageTypeACustomListsType', 98 | 'IntListBlock', 99 | 'StringListBlock', 100 | 'DateTimeListBlock', 101 | 'Test_app_2App2SnippetListBlock', 102 | 'Settings', 103 | 'Test_app_2SiteBranding', 104 | 'FormFieldFieldType', 105 | 'FormField', 106 | 'FormError', 107 | 'Test_app_1FormPage', 108 | 'Test_app_1FormField', 109 | 'Test_app_1FormPageMutation', 110 | 'Test_app_2AnotherSetting', 111 | } 112 | if IS_RELAY: 113 | expected.update([ 114 | 'PageEdge', 115 | 'Node', 116 | 'PageConnection', 117 | 'PageInfo', 118 | 'BasePageEdge', 119 | 'BasePageConnection' 120 | ]) 121 | 122 | assert set(x['name'] for x in response.json()['data']['__schema']['types']) == expected 123 | -------------------------------------------------------------------------------- /wagtail_graphql/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | __version__ = '0.2.0' 3 | -------------------------------------------------------------------------------- /wagtail_graphql/actions.py: -------------------------------------------------------------------------------- 1 | # python 2 | import string 3 | from typing import Type, Set 4 | # django 5 | from django.utils.text import camel_case_to_spaces 6 | # graphene 7 | import graphene 8 | from graphene.types.generic import GenericScalar 9 | # graphene_django 10 | from graphene_django import DjangoObjectType 11 | # wagtail 12 | from wagtail.core.fields import StreamField 13 | from wagtail.core.models import Page as wagtailPage 14 | # wagtail forms 15 | from wagtail.contrib.forms.models import AbstractForm 16 | # wagtail settings 17 | from wagtail.contrib.settings.models import BaseSetting 18 | # app 19 | from .registry import registry 20 | from .permissions import with_page_permissions 21 | from .settings import url_prefix_for_site, RELAY 22 | # app types 23 | from .types import ( 24 | Page, 25 | Settings, 26 | FormError, 27 | FormField, 28 | ) 29 | 30 | 31 | def _add_form(cls: Type[AbstractForm], node: str, dict_params: dict) -> Type[graphene.Mutation]: 32 | if node in registry.forms: # pragma: no cover 33 | return registry.forms[node] 34 | 35 | registry.page_prefetch_fields.add(cls.__name__.lower()) 36 | dict_params['Meta'].interfaces += (Page,) 37 | dict_params['form_fields'] = graphene.List(FormField) 38 | 39 | def form_fields(self, _info): 40 | return list(FormField(name=field_.clean_name, field_type=field_.field_type, 41 | label=field_.label, required=field_.required, 42 | help_text=field_.help_text, choices=field_.choices, 43 | default_value=field_.default_value) 44 | for field_ in self.form_fields.all()) 45 | 46 | dict_params['resolve_form_fields'] = form_fields 47 | registry.pages[cls] = type(node, (DjangoObjectType,), dict_params) 48 | 49 | args = type("Arguments", (), {'values': GenericScalar(), 50 | "url": graphene.String(required=True)}) 51 | _node = node 52 | 53 | def mutate(_self, info, url, values): 54 | url_prefix = url_prefix_for_site(info) 55 | query = wagtailPage.objects.filter(url_path=url_prefix + url.rstrip('/') + '/') 56 | instance = with_page_permissions( 57 | info.context, 58 | query.specific() 59 | ).live().first() 60 | user = info.context.user 61 | # convert camelcase to dashes 62 | values = {camel_case_to_spaces(k).replace(' ', '-'): v for k, v in values.items()} 63 | form = instance.get_form(values, None, page=instance, user=user) 64 | if form.is_valid(): 65 | # form_submission 66 | instance.process_form_submission(form) 67 | return registry.forms[_node](result="OK") 68 | else: 69 | return registry.forms[_node](result="FAIL", errors=[FormError(*err) for err in form.errors.items()]) 70 | 71 | dict_params = { 72 | "Arguments": args, 73 | "mutate": mutate, 74 | "result": graphene.String(), 75 | "errors": graphene.List(FormError), 76 | } 77 | tp = type(node + "Mutation", (graphene.Mutation,), dict_params) # type: Type[graphene.Mutation] 78 | registry.forms[node] = tp 79 | return tp 80 | 81 | 82 | def _add_page(cls: Type[wagtailPage], node: str, dict_params: dict) -> Type[DjangoObjectType]: 83 | if cls in registry.pages: # pragma: no cover 84 | return registry.pages[cls] 85 | registry.page_prefetch_fields.add(cls.__name__.lower()) 86 | dict_params['Meta'].interfaces += (Page,) 87 | tp = type(node, (DjangoObjectType,), dict_params) # type: Type[DjangoObjectType] 88 | registry.pages[cls] = tp 89 | return tp 90 | 91 | 92 | def _add_setting(cls: Type[BaseSetting], node: str, dict_params: dict) -> Type[DjangoObjectType]: 93 | if not hasattr(cls, 'name'): # we always need a name field 94 | cls.name = cls.__name__ 95 | dict_params['Meta'].interfaces += (Settings,) 96 | tp = type(node, (DjangoObjectType,), dict_params) # type: Type[DjangoObjectType] 97 | registry.settings[node] = (tp, cls) 98 | return tp 99 | 100 | 101 | def _add_snippet(cls: type, node: str, dict_params: dict) -> Type[DjangoObjectType]: 102 | if cls in registry.snippets: # pragma: no cover 103 | return registry.snippets[cls] 104 | tp = type(node, (DjangoObjectType,), dict_params) # type: Type[DjangoObjectType] 105 | registry.snippets[cls] = tp 106 | registry.snippets_by_name[node] = tp 107 | return tp 108 | 109 | 110 | def _add_django_model(_cls: type, node: str, dict_params: dict) -> Type[DjangoObjectType]: 111 | if node in registry.django: # pragma: no cover 112 | return registry.django[node] 113 | tp = type(node, (DjangoObjectType,), dict_params) # type: Type[DjangoObjectType] 114 | registry.django[node] = tp 115 | return tp 116 | 117 | 118 | def _add_streamfields(cls: wagtailPage, node: str, dict_params: dict, app: str, prefix: str) -> None: 119 | from .types.streamfield import ( 120 | block_handler, 121 | stream_field_handler, 122 | ) 123 | 124 | for field in cls._meta.fields: 125 | if isinstance(field, StreamField): 126 | field_name = field.name 127 | stream_field_name = f"{node}{string.capwords(field_name, sep='_').replace('_', '')}" 128 | blocks = field.stream_block.child_blocks 129 | 130 | handlers = dict( 131 | (name, block_handler(block, app, prefix)) 132 | for name, block in blocks.items() 133 | ) 134 | 135 | f, resolve = stream_field_handler( 136 | stream_field_name, 137 | field_name, 138 | handlers 139 | ) 140 | 141 | dict_params.update({ 142 | field.name: f, 143 | "resolve_" + field.name: resolve 144 | }) 145 | 146 | 147 | def _register_model(registered: Set[type], cls: type, snippet: bool, 148 | app: str, prefix: str, override_name=None) -> None: 149 | if cls in registered: 150 | return 151 | 152 | prefix = prefix.format(app=string.capwords(app), 153 | cls=cls.__name__) 154 | node = override_name or prefix + cls.__name__ 155 | 156 | # dict parameters to create GraphQL type 157 | class Meta: 158 | model = cls 159 | interfaces = (graphene.relay.Node, ) if RELAY else tuple() 160 | 161 | dict_params = {'Meta': Meta} 162 | 163 | # add streamfield handlers 164 | _add_streamfields(cls, node, dict_params, app, prefix) 165 | 166 | if snippet: 167 | _add_snippet(cls, node, dict_params) 168 | elif issubclass(cls, AbstractForm): 169 | _add_form(cls, node, dict_params) 170 | elif issubclass(cls, wagtailPage): 171 | _add_page(cls, node, dict_params) 172 | elif issubclass(cls, BaseSetting): 173 | _add_setting(cls, node, dict_params) 174 | else: # Django Model 175 | _add_django_model(cls, node, dict_params) 176 | 177 | registered.add(cls) 178 | 179 | 180 | def add_app(app: str, prefix: str = '{app}') -> None: 181 | from django.contrib.contenttypes.models import ContentType 182 | from wagtail.snippets.models import get_snippet_models 183 | snippets = get_snippet_models() 184 | models = [mdl.model_class() 185 | for mdl in ContentType.objects.filter(app_label=app).all()] 186 | snippets = [s for s in snippets if s in models] 187 | to_register = [x for x in snippets + models if x is not None] 188 | registered: Set = set() 189 | 190 | # prefetch content_types 191 | ContentType.objects.get_for_models(*to_register) 192 | 193 | for cls in to_register: 194 | _register_model(registered, cls, cls in snippets, app, prefix) 195 | 196 | 197 | def add_apps_with_settings(settings: dict) -> None: 198 | apps = settings.get('APPS', []) 199 | 200 | for app in apps: 201 | prefixes = settings.get('PREFIX', {}) 202 | if isinstance(prefixes, str): 203 | prefix = prefixes # pragma: no cover 204 | else: 205 | prefix = prefixes.get(app, '{app}') 206 | add_app(app, prefix=prefix) 207 | if not apps: # pragma: no cover 208 | import logging 209 | logging.warning("No APPS specified for wagtail_graphql") 210 | 211 | 212 | def add_apps() -> None: 213 | from .settings import SETTINGS 214 | add_apps_with_settings(SETTINGS) 215 | 216 | 217 | # standard page 218 | _register_model(set(), wagtailPage, False, 'wagtailcore', '', override_name='BasePage') 219 | -------------------------------------------------------------------------------- /wagtail_graphql/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig # pragma: no cover 2 | 3 | 4 | class ApiConfig(AppConfig): # pragma: no cover 5 | name = 'wagtail_graphql' 6 | -------------------------------------------------------------------------------- /wagtail_graphql/permissions.py: -------------------------------------------------------------------------------- 1 | # python 2 | from typing import Any, Union 3 | # django 4 | from django.db.models import Q 5 | from django.contrib.auth.models import AnonymousUser 6 | # wagtail 7 | from wagtail.core.query import PageQuerySet 8 | from wagtail.core.models import PageViewRestriction, CollectionViewRestriction 9 | from wagtail.images.models import ImageQuerySet 10 | from wagtail.documents.models import DocumentQuerySet 11 | 12 | 13 | def with_page_permissions(request: Any, queryset: PageQuerySet) -> PageQuerySet: 14 | user = request.user 15 | 16 | # Filter by site 17 | if request.site: 18 | queryset = queryset.descendant_of(request.site.root_page, inclusive=True) 19 | else: 20 | # No sites configured 21 | return queryset.none() # pragma: no cover 22 | 23 | # Get live pages that are public and check groups and login permissions 24 | if user == AnonymousUser: 25 | queryset = queryset.public() 26 | elif user.is_superuser: 27 | pass 28 | else: 29 | current_user_groups = user.groups.all() 30 | q = Q() 31 | for restriction in PageViewRestriction.objects.all(): 32 | if (restriction.restriction_type == PageViewRestriction.PASSWORD) or \ 33 | (restriction.restriction_type == PageViewRestriction.LOGIN and not user.is_authenticated) or \ 34 | (restriction.restriction_type == PageViewRestriction.GROUPS and 35 | not any(group in current_user_groups for group in restriction.groups.all()) 36 | ): 37 | q = ~queryset.descendant_of_q(restriction.page, inclusive=True) 38 | queryset = queryset.filter(q).live() 39 | 40 | return queryset 41 | 42 | 43 | CollectionQSType = Union[ImageQuerySet, DocumentQuerySet] 44 | 45 | 46 | def with_collection_permissions(request: Any, queryset: CollectionQSType) -> CollectionQSType: 47 | user = request.user 48 | 49 | # Get live pages that are public and check groups and login permissions 50 | if user == AnonymousUser: 51 | queryset = queryset.public() 52 | elif user.is_superuser: 53 | pass 54 | else: 55 | current_user_groups = user.groups.all() 56 | q = Q() 57 | for restriction in CollectionViewRestriction.objects.all(): 58 | if (restriction.restriction_type == CollectionViewRestriction.PASSWORD) or \ 59 | (restriction.restriction_type == CollectionViewRestriction.LOGIN and not user.is_authenticated) or \ 60 | (restriction.restriction_type == CollectionViewRestriction.GROUPS and 61 | not any(group in current_user_groups for group in restriction.groups.all()) 62 | ): 63 | q &= ~Q(collection=restriction.collection) 64 | # q &= ~queryset.filter(collection) descendant_of_q(restriction.page, inclusive=True) 65 | queryset = queryset.filter(q) 66 | 67 | return queryset 68 | -------------------------------------------------------------------------------- /wagtail_graphql/registry.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class RegistryItem(dict): 4 | @property 5 | def types(self) -> tuple: 6 | return tuple(self.values()) 7 | 8 | 9 | class Registry: 10 | _django = RegistryItem() 11 | _forms = RegistryItem() 12 | _pages = RegistryItem() 13 | _settings = RegistryItem() 14 | _snippets = RegistryItem() 15 | _snippets_by_name = RegistryItem() 16 | _streamfield_blocks = RegistryItem() 17 | _streamfield_scalar_blocks = RegistryItem() 18 | _page_prefetch = { 19 | 'content_type', 'owner', 20 | 'live_revision', 'page_ptr' 21 | } 22 | 23 | @property 24 | def blocks(self) -> RegistryItem: 25 | return self._streamfield_blocks 26 | 27 | @property 28 | def scalar_blocks(self) -> RegistryItem: 29 | return self._streamfield_scalar_blocks 30 | 31 | @property 32 | def django(self) -> RegistryItem: 33 | return self._django 34 | 35 | @property 36 | def forms(self) -> RegistryItem: 37 | return self._forms 38 | 39 | @property 40 | def pages(self) -> RegistryItem: 41 | return self._pages 42 | 43 | @property 44 | def settings(self) -> RegistryItem: 45 | return self._settings 46 | 47 | @property 48 | def snippets(self) -> RegistryItem: 49 | return self._snippets 50 | 51 | @property 52 | def snippets_by_name(self) -> RegistryItem: 53 | return self._snippets_by_name 54 | 55 | @property 56 | def rsnippets(self) -> RegistryItem: 57 | return RegistryItem((v, k) for k, v in self._snippets.items()) 58 | 59 | @property 60 | def page_prefetch_fields(self) -> set: 61 | return self._page_prefetch 62 | 63 | @property 64 | def models(self) -> dict: 65 | models: dict = {} 66 | models.update(self.pages) 67 | models.update(self.snippets) 68 | models.update(self.forms) 69 | models.update(self.django) 70 | models.update((k, v[0]) for k, v in self.settings.items()) 71 | models.update((k, v) for k, v in self.blocks.items() if not isinstance(v, tuple)) 72 | models.update(self.scalar_blocks.items()) 73 | return models 74 | 75 | @property 76 | def rmodels(self) -> dict: 77 | return dict((v, k) for k, v in self.models.items()) 78 | 79 | 80 | registry = Registry() 81 | -------------------------------------------------------------------------------- /wagtail_graphql/relay.py: -------------------------------------------------------------------------------- 1 | # graphene 2 | import graphene 3 | # app 4 | from .settings import RELAY 5 | 6 | 7 | if RELAY: 8 | class RelayMixin: 9 | node = graphene.relay.Node.Field() 10 | else: 11 | class RelayMixin: # type: ignore 12 | pass 13 | -------------------------------------------------------------------------------- /wagtail_graphql/schema.py: -------------------------------------------------------------------------------- 1 | # typings 2 | from typing import Any # noqa 3 | # django 4 | from django.utils.text import camel_case_to_spaces 5 | # graphql 6 | from graphql import ResolveInfo 7 | # graphene 8 | import graphene 9 | # graphene_django 10 | from graphene_django.converter import String 11 | # app 12 | from .relay import RelayMixin 13 | from .registry import registry 14 | from .actions import add_apps 15 | # add all the apps from the settings 16 | add_apps() 17 | # mixins 18 | from .types import ( # noqa: E402 19 | AuthQueryMixin, LoginMutation, LogoutMutation, 20 | DocumentQueryMixin, 21 | ImageQueryMixin, 22 | InfoQueryMixin, 23 | MenusQueryMixin, 24 | PagesQueryMixin, 25 | SettingsQueryMixin, 26 | SnippetsQueryMixin, 27 | ) 28 | 29 | 30 | # api version 31 | GRAPHQL_API_FORMAT = (0, 2, 0) 32 | 33 | # mixins 34 | AuthQueryMixin_ = AuthQueryMixin() # type: Any 35 | DocumentQueryMixin_ = DocumentQueryMixin() # type: Any 36 | ImageQueryMixin_ = ImageQueryMixin() # type: Any 37 | InfoQueryMixin_ = InfoQueryMixin() # type: Any 38 | MenusQueryMixin_ = MenusQueryMixin() # type: Any 39 | PagesQueryMixin_ = PagesQueryMixin() # type: Any 40 | SettingsQueryMixin_ = SettingsQueryMixin() # type: Any 41 | SnippetsQueryMixin_ = SnippetsQueryMixin() # type: Any 42 | 43 | 44 | class Query(graphene.ObjectType, 45 | AuthQueryMixin_, 46 | DocumentQueryMixin_, 47 | ImageQueryMixin_, 48 | InfoQueryMixin_, 49 | MenusQueryMixin_, 50 | PagesQueryMixin_, 51 | SettingsQueryMixin_, 52 | SnippetsQueryMixin_, 53 | RelayMixin 54 | ): 55 | # API Version 56 | format = graphene.Field(String) 57 | 58 | def resolve_format(self, _info: ResolveInfo): 59 | return '%d.%d.%d' % GRAPHQL_API_FORMAT 60 | 61 | 62 | def mutation_parameters() -> dict: 63 | dict_params = { 64 | 'login': LoginMutation.Field(), 65 | 'logout': LogoutMutation.Field(), 66 | } 67 | dict_params.update((camel_case_to_spaces(n).replace(' ', '_'), mut.Field()) 68 | for n, mut in registry.forms.items()) 69 | return dict_params 70 | 71 | 72 | Mutations = type("Mutation", 73 | (graphene.ObjectType,), 74 | mutation_parameters() 75 | ) 76 | 77 | schema = graphene.Schema( 78 | query=Query, 79 | mutation=Mutations, 80 | types=list(registry.models.values()) 81 | ) 82 | -------------------------------------------------------------------------------- /wagtail_graphql/settings.py: -------------------------------------------------------------------------------- 1 | # django 2 | from django.conf import settings 3 | # graphql 4 | from graphql import ResolveInfo 5 | 6 | # settings 7 | if hasattr(settings, 'GRAPHQL_API'): 8 | SETTINGS = settings.GRAPHQL_API 9 | else: # pragma: no cover 10 | SETTINGS = {} 11 | URL_PREFIX = SETTINGS.get('URL_PREFIX', {}) 12 | LOAD_GENERIC_SCALARS = SETTINGS.get('GENERIC_SCALARS', True) 13 | RELAY = SETTINGS.get('RELAY', False) 14 | 15 | # wagtail settings 16 | try: 17 | from wagtail.contrib.settings.registry import registry as settings_registry 18 | except ImportError: # pragma: no cover 19 | settings_registry = None 20 | 21 | 22 | def url_prefix_for_site(info: ResolveInfo): 23 | hostname = info.context.site.hostname 24 | return URL_PREFIX.get( 25 | hostname, 26 | info.context.site.root_page.url_path.rstrip('/') 27 | ) 28 | -------------------------------------------------------------------------------- /wagtail_graphql/types/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import ( 2 | Page, 3 | Site, 4 | User, 5 | # mixins 6 | InfoQueryMixin, 7 | PagesQueryMixin, 8 | ) 9 | from .auth import ( 10 | LoginMutation, 11 | LogoutMutation, 12 | # mixins 13 | AuthQueryMixin, 14 | ) 15 | from .documents import ( 16 | Document, 17 | # mixins 18 | DocumentQueryMixin, 19 | ) 20 | from .forms import FormField, FormError 21 | from .images import ( 22 | Image, 23 | # mixins 24 | ImageQueryMixin, 25 | ) 26 | from .settings import Settings, SettingsQueryMixin 27 | from .snippets import SnippetsQueryMixin 28 | # noinspection PyUnresolvedReferences 29 | from . import converters # noqa: F401 30 | 31 | __all__ = [ 32 | # core 33 | 'Page', 34 | 'Settings', 35 | 'Site', 36 | 'User', 37 | # auth 38 | 'AuthQueryMixin', 39 | 'LoginMutation', 40 | 'LogoutMutation', 41 | # documents 42 | 'Document', 43 | # forms 44 | 'FormError', 45 | 'FormField', 46 | # images 47 | 'Image', 48 | # mixins 49 | 'DocumentQueryMixin', 50 | 'ImageQueryMixin', 51 | 'InfoQueryMixin', 52 | 'MenusQueryMixin', 53 | 'PagesQueryMixin', 54 | 'SettingsQueryMixin', 55 | 'SnippetsQueryMixin', 56 | ] 57 | 58 | # menus 59 | 60 | try: 61 | from django.conf import settings 62 | if 'wagtailmenus' not in settings.INSTALLED_APPS: 63 | raise ImportError() # pragma: no cover 64 | 65 | from .menus import MenusQueryMixin, Menu, MenuItem, SecondaryMenu, SecondaryMenuItem # noqa: F401 66 | 67 | __all__.extend([ 68 | # menus 69 | 'Menu', 70 | 'MenuItem', 71 | 'SecondaryMenu', 72 | 'SecondaryMenuItem', 73 | ]) 74 | 75 | HAS_WAGTAILMENUS = True 76 | except ImportError: # pragma: no cover 77 | def MenusQueryMixin(): 78 | # type: ignore 79 | class _MenusQueryMixin: 80 | pass 81 | return _MenusQueryMixin 82 | HAS_WAGTAILMENUS = False 83 | -------------------------------------------------------------------------------- /wagtail_graphql/types/auth.py: -------------------------------------------------------------------------------- 1 | # django 2 | from django.contrib.auth.models import User as wagtailUser, AnonymousUser 3 | # graphene 4 | import graphene 5 | from graphql.execution.base import ResolveInfo 6 | # app types 7 | from .core import User 8 | 9 | 10 | def AuthQueryMixin(): 11 | class Mixin: 12 | # User information 13 | user = graphene.Field(User) 14 | 15 | def resolve_user(self, info: ResolveInfo): 16 | user = info.context.user 17 | if isinstance(user, AnonymousUser): 18 | return wagtailUser(id='-1', username='anonymous') 19 | return user 20 | return Mixin 21 | 22 | 23 | class LoginMutation(graphene.Mutation): 24 | class Arguments: 25 | username = graphene.String(required=True) 26 | password = graphene.String(required=True) 27 | 28 | user = graphene.Field(User) 29 | 30 | def mutate(self, info, username, password): 31 | from django.contrib.auth import authenticate, login 32 | user = authenticate(info.context, username=username, password=password) 33 | if user is not None: 34 | login(info.context, user) 35 | else: 36 | user = wagtailUser(id='-1', username='anonymous') 37 | return LoginMutation(user=user) 38 | 39 | 40 | class LogoutMutation(graphene.Mutation): 41 | user = graphene.Field(User) 42 | 43 | def mutate(self, info): 44 | from django.contrib.auth import logout 45 | logout(info.context) 46 | return LogoutMutation(wagtailUser(id='-1', username='anonymous')) 47 | -------------------------------------------------------------------------------- /wagtail_graphql/types/converters.py: -------------------------------------------------------------------------------- 1 | # django 2 | from django.db import models 3 | # graphene_django 4 | from graphene import String 5 | from graphene_django.converter import convert_django_field 6 | 7 | 8 | @convert_django_field.register(models.DecimalField) 9 | @convert_django_field.register(models.DurationField) 10 | def convert_force_string(field, _registry=None): 11 | return String(description=field.help_text, required=not field.null) 12 | -------------------------------------------------------------------------------- /wagtail_graphql/types/core.py: -------------------------------------------------------------------------------- 1 | # typings 2 | from typing import cast 3 | # django 4 | from django.contrib.auth.models import User as wagtailUser 5 | from django.contrib.contenttypes.models import ContentType 6 | # graphql 7 | from graphql.execution.base import ResolveInfo 8 | from graphql.language.ast import InlineFragment 9 | # graphene 10 | import graphene 11 | # graphene_django 12 | from graphene_django import DjangoObjectType 13 | from graphene_django.converter import convert_django_field, String, List 14 | # wagtail 15 | from wagtail.core.models import Page as wagtailPage, Site as wagtailSite 16 | from taggit.managers import TaggableManager, 17 | from modelcluster.tags import ClusterTaggableManager 18 | from wagtail.core.utils import camelcase_to_underscore 19 | # app 20 | from ..settings import url_prefix_for_site, RELAY 21 | from ..registry import registry 22 | from ..permissions import with_page_permissions 23 | 24 | 25 | class User(DjangoObjectType): 26 | class Meta: 27 | model = wagtailUser 28 | exclude_fields = ['password'] 29 | 30 | 31 | class Site(DjangoObjectType): 32 | class Meta: 33 | model = wagtailSite 34 | 35 | 36 | interface_cls: graphene.Interface = graphene.relay.Node if RELAY else graphene.Interface 37 | 38 | 39 | class Page(interface_cls): 40 | if not RELAY: # use opaque ids in Relay 41 | id = graphene.Int(required=True) 42 | title = graphene.String(required=True) 43 | url_path = graphene.String() 44 | content_type = graphene.String() 45 | slug = graphene.String(required=True) 46 | path = graphene.String() 47 | depth = graphene.Int() 48 | seoTitle = graphene.String() 49 | numchild = graphene.Int() 50 | revision = graphene.Int() 51 | first_published_at = graphene.DateTime() 52 | last_published_at = graphene.DateTime() 53 | latest_revision_created_at = graphene.DateTime() 54 | live = graphene.Boolean() 55 | go_live_at = graphene.DateTime() 56 | expire_at = graphene.DateTime() 57 | expired = graphene.Boolean() 58 | locked = graphene.Boolean() 59 | draft_title = graphene.String() 60 | has_unpublished_changes = graphene.Boolean() 61 | 62 | def resolve_content_type(self, _info: ResolveInfo): 63 | self.content_type = ContentType.objects.get_for_model(self) 64 | return self.content_type.app_label + '.' + self.content_type.model_class().__name__ 65 | 66 | @classmethod 67 | def resolve_type(cls, instance, info: ResolveInfo) -> 'Page': 68 | mdl = ContentType.objects.get_for_model(instance).model_class() 69 | try: 70 | model = registry.pages[mdl] 71 | except KeyError: # pragma: no cover 72 | raise ValueError("Model %s is not a registered GraphQL type" % mdl) 73 | return model 74 | 75 | def resolve_url_path(self, info: ResolveInfo) -> str: 76 | self.url_path = cast(str, self.url_path) 77 | url_prefix = url_prefix_for_site(info) 78 | url = self.url_path if not self.url_path.startswith(url_prefix) else self.url_path[len(url_prefix):] 79 | return url.rstrip('/') 80 | 81 | if RELAY: 82 | children = graphene.ConnectionField(lambda *x: PageConnection) 83 | else: 84 | children = graphene.List(lambda *x: Page) 85 | 86 | def resolve_children(self, info: ResolveInfo, **_kwargs): 87 | query = wagtailPage.objects.child_of(self) 88 | return with_page_permissions( 89 | info.context, 90 | query.specific() 91 | ).live().order_by('path').all() 92 | 93 | 94 | # https://jossingram.wordpress.com/2018/04/19/wagtail-and-graphql/ 95 | class FlatTags(graphene.String): 96 | 97 | @classmethod 98 | def serialize(cls, value): 99 | tagsList = [] 100 | for tag in value.all(): 101 | tagsList.append(tag.name) 102 | return tagsList 103 | 104 | @convert_django_field.register(ClusterTaggableManager) 105 | def convert_tag_field_to_string(field, registry=None): 106 | return graphene.Field(FlatTags, 107 | description=field.help_text, 108 | required=not field.null) 109 | 110 | @convert_django_field.register(TaggableManager) 111 | def convert_field_to_string(field, _registry=None): 112 | return List(String, description=field.help_text, required=not field.null) 113 | 114 | 115 | def _resolve_preview(request, view): # pragma: no cover 116 | from django.http import QueryDict 117 | page = view.get_page() 118 | post_data, timestamp = request.session.get(view.session_key, (None, None)) 119 | if not isinstance(post_data, str): 120 | post_data = '' 121 | form = view.get_form(page, QueryDict(post_data)) 122 | if not form.is_valid(): 123 | raise ValueError("Invalid preview data") 124 | form.save(commit=False) 125 | return page 126 | 127 | 128 | if RELAY: 129 | class PageConnection(graphene.relay.Connection): 130 | class Meta: 131 | node = Page 132 | 133 | class Edge: 134 | pass 135 | 136 | 137 | def PagesQueryMixin(): # noqa: C901 138 | class Mixin: 139 | if RELAY: 140 | pages = graphene.ConnectionField(PageConnection) 141 | else: 142 | pages = graphene.List(Page, parent=graphene.Int()) 143 | 144 | page = graphene.Field(Page, 145 | id=graphene.Int(), 146 | url=graphene.String(), 147 | revision=graphene.Int(), 148 | ) 149 | preview = graphene.Field(Page, 150 | id=graphene.Int(required=True), 151 | ) 152 | 153 | preview_add = graphene.Field(Page, 154 | app_name=graphene.String(), 155 | model_name=graphene.String(), 156 | parent=graphene.Int(required=True), 157 | ) 158 | 159 | def resolve_pages(self, info: ResolveInfo, parent: int = None, **_kwargs): 160 | query = wagtailPage.objects 161 | 162 | # prefetch specific type pages 163 | selections = set(camelcase_to_underscore(f.name.value) 164 | for f in info.field_asts[0].selection_set.selections 165 | if not isinstance(f, InlineFragment)) 166 | for pf in registry.page_prefetch_fields.intersection(selections): 167 | query = query.select_related(pf) 168 | 169 | if parent is not None: 170 | parent_page = wagtailPage.objects.filter(id=parent).first() 171 | if parent_page is None: 172 | raise ValueError(f'Page id={parent} not found.') 173 | query = query.child_of(parent_page) 174 | 175 | return with_page_permissions( 176 | info.context, 177 | query.specific() 178 | ).live().order_by('path').all() 179 | 180 | def resolve_page(self, info: ResolveInfo, id: int = None, url: str = None, revision: int = None): 181 | query = wagtailPage.objects 182 | if id is not None: 183 | query = query.filter(id=id) 184 | elif url is not None: 185 | url_prefix = url_prefix_for_site(info) 186 | query = query.filter(url_path=url_prefix + url.rstrip('/') + '/') 187 | else: # pragma: no cover 188 | raise ValueError("One of 'id' or 'url' must be specified") 189 | page = with_page_permissions( 190 | info.context, 191 | query.specific() 192 | ).live().first() 193 | 194 | if page is None: 195 | return None 196 | 197 | if revision is not None: 198 | if revision == -1: 199 | rev = page.get_latest_revision() 200 | else: 201 | rev = page.revisions.filter(id=revision).first() 202 | if not rev: 203 | raise ValueError("Revision %d doesn't exist" % revision) 204 | 205 | page = rev.as_page_object() 206 | page.revision = rev.id 207 | return page 208 | 209 | return page 210 | 211 | def resolve_preview(self, info: ResolveInfo, id: int): # pragma: no cover 212 | from wagtail.admin.views.pages import PreviewOnEdit 213 | request = info.context 214 | view = PreviewOnEdit(args=('%d' % id, ), request=request) 215 | return _resolve_preview(request, view) 216 | 217 | def resolve_preview_add(self, info: ResolveInfo, app_name: str = 'wagtailcore', 218 | model_name: str = 'page', parent: int = None): # pragma: no cover 219 | from wagtail.admin.views.pages import PreviewOnCreate 220 | request = info.context 221 | view = PreviewOnCreate(args=(app_name, model_name, str(parent)), request=request) 222 | page = _resolve_preview(request, view) 223 | page.id = 0 # force an id, since our schema assumes page.id is an Int! 224 | return page 225 | 226 | # Show in Menu 227 | show_in_menus = graphene.List(Page) 228 | 229 | def resolve_show_in_menus(self, info: ResolveInfo): 230 | return with_page_permissions( 231 | info.context, 232 | wagtailPage.objects.filter(show_in_menus=True) 233 | ).live().order_by('path') 234 | return Mixin 235 | 236 | 237 | def InfoQueryMixin(): 238 | class Mixin: 239 | # Root 240 | root = graphene.Field(Site) 241 | 242 | def resolve_root(self, info: ResolveInfo): 243 | user = info.context.user 244 | if user.is_superuser: 245 | return info.context.site 246 | else: 247 | return None 248 | return Mixin 249 | -------------------------------------------------------------------------------- /wagtail_graphql/types/documents.py: -------------------------------------------------------------------------------- 1 | # graphql 2 | from graphql.execution.base import ResolveInfo 3 | # graphene 4 | import graphene 5 | # graphene_django 6 | from graphene_django import DjangoObjectType 7 | # graphene_django_optimizer 8 | import graphene_django_optimizer as gql_optimizer 9 | # wagtail documents 10 | from wagtail.documents.models import Document as wagtailDocument 11 | # app 12 | from ..permissions import with_collection_permissions 13 | 14 | 15 | class Document(DjangoObjectType): 16 | class Meta: 17 | model = wagtailDocument 18 | 19 | url = graphene.String() 20 | filename = graphene.String() 21 | file_extension = graphene.String() 22 | 23 | def resolve_tags(self: wagtailDocument, _info: ResolveInfo): 24 | return self.tags.all() 25 | 26 | 27 | def DocumentQueryMixin(): 28 | class Mixin: 29 | documents = graphene.List(Document) 30 | document = graphene.Field(Document, 31 | id=graphene.Int(required=True)) 32 | 33 | def resolve_documents(self, info: ResolveInfo): 34 | return with_collection_permissions( 35 | info.context, 36 | gql_optimizer.query( 37 | wagtailDocument.objects.all(), 38 | info 39 | ) 40 | ) 41 | 42 | def resolve_document(self, info: ResolveInfo, id: int): 43 | doc = with_collection_permissions( 44 | info.context, 45 | gql_optimizer.query( 46 | wagtailDocument.objects.filter(id=id), 47 | info 48 | ) 49 | ).first() 50 | return doc 51 | return Mixin 52 | -------------------------------------------------------------------------------- /wagtail_graphql/types/forms.py: -------------------------------------------------------------------------------- 1 | # graphene 2 | import graphene 3 | # graphene_django 4 | from graphene_django.converter import String, Boolean 5 | 6 | 7 | class FormField(graphene.ObjectType): 8 | name = graphene.Field(String) 9 | field_type = graphene.Field(String) 10 | help_text = graphene.Field(String) 11 | required = graphene.Field(Boolean) 12 | choices = graphene.Field(String) 13 | default_value = graphene.Field(String) 14 | label = graphene.Field(String) 15 | 16 | 17 | class FormError(graphene.ObjectType): 18 | name = graphene.Field(String) 19 | errors = graphene.List(String) 20 | -------------------------------------------------------------------------------- /wagtail_graphql/types/images.py: -------------------------------------------------------------------------------- 1 | # graphql 2 | from graphql.execution.base import ResolveInfo 3 | # django 4 | from django.urls import reverse 5 | # graphene 6 | import graphene 7 | # graphene_django 8 | from graphene_django import DjangoObjectType 9 | from graphene_django.converter import convert_django_field 10 | # graphene_django_optimizer 11 | import graphene_django_optimizer as gql_optimizer 12 | # wagtail images 13 | from wagtail.images.models import Image as wagtailImage 14 | from wagtail.images.views.serve import generate_signature 15 | # app 16 | from ..permissions import with_collection_permissions 17 | 18 | 19 | @convert_django_field.register(wagtailImage) 20 | def convert_image(field, _registry=None): 21 | return Image(description=field.help_text, required=not field.null) # pragma: no cover 22 | 23 | 24 | class Rect(graphene.ObjectType): 25 | left = graphene.Int() 26 | top = graphene.Int() 27 | right = graphene.Int() 28 | bottom = graphene.Int() 29 | 30 | x = graphene.Int() 31 | y = graphene.Int() 32 | height = graphene.Int() 33 | width = graphene.Int() 34 | 35 | 36 | class Image(DjangoObjectType): 37 | class Meta: 38 | model = wagtailImage 39 | exclude_fields = [ 40 | 'focal_point_x', 41 | 'focal_point_y', 42 | 'focal_point_width', 43 | 'focal_point_height', 44 | ] 45 | 46 | has_focal_point = graphene.Boolean() 47 | focal_point = graphene.Field(Rect) 48 | 49 | url = graphene.String(rendition=graphene.String()) 50 | url_link = graphene.String(rendition=graphene.String()) 51 | 52 | def resolve_has_focal_point(self: wagtailImage, _info: ResolveInfo): 53 | return self.has_focal_point() 54 | 55 | def resolve_focal_point(self: wagtailImage, _info: ResolveInfo): 56 | return self.get_focal_point() 57 | 58 | def resolve_tags(self: wagtailImage, _info: ResolveInfo): 59 | return self.tags.all() 60 | 61 | def resolve_url(self: wagtailImage, _info: ResolveInfo, rendition: str = None): 62 | if not rendition: 63 | if not self.has_focal_point(): 64 | rendition = "original" 65 | else: 66 | fp = self.get_focal_point() 67 | rendition = 'fill-%dx%d-c100' % (fp.width, fp.height) 68 | 69 | return generate_image_url(self, rendition) 70 | 71 | def resolve_url_link(self: wagtailImage, _info: ResolveInfo, rendition: str = None): 72 | if not rendition: 73 | if not self.has_focal_point(): 74 | rendition = "original" 75 | else: 76 | fp = self.get_focal_point() 77 | rendition = 'fill-%dx%d-c100' % (fp.width, fp.height) 78 | return self.get_rendition(rendition).url 79 | 80 | 81 | def generate_image_url(image: wagtailImage, filter_spec: str) -> str: 82 | signature = generate_signature(image.pk, filter_spec) 83 | url = reverse('wagtailimages_serve', args=(signature, image.pk, filter_spec)) 84 | return url 85 | 86 | 87 | def ImageQueryMixin(): 88 | class Mixin: 89 | images = graphene.List(Image) 90 | image = graphene.Field(Image, 91 | id=graphene.Int(required=True)) 92 | 93 | def resolve_images(self, info: ResolveInfo): 94 | return with_collection_permissions( 95 | info.context, 96 | gql_optimizer.query( 97 | wagtailImage.objects.all(), 98 | info 99 | ) 100 | ) 101 | 102 | def resolve_image(self, info: ResolveInfo, id: int): 103 | image = with_collection_permissions( 104 | info.context, 105 | gql_optimizer.query( 106 | wagtailImage.objects.filter(id=id), 107 | info 108 | ) 109 | ).first() 110 | return image 111 | return Mixin 112 | -------------------------------------------------------------------------------- /wagtail_graphql/types/menus.py: -------------------------------------------------------------------------------- 1 | # python 2 | from typing import List 3 | # graphql 4 | from graphql import ResolveInfo 5 | # graphene_django 6 | import graphene 7 | from graphene_django import DjangoObjectType 8 | # wagtailmenus 9 | from wagtailmenus.models import FlatMenu, FlatMenuItem, MainMenu, MainMenuItem 10 | 11 | 12 | class MenuItem(DjangoObjectType): 13 | class Meta: 14 | model = MainMenuItem 15 | 16 | 17 | class Menu(DjangoObjectType): 18 | class Meta: 19 | model = MainMenu 20 | only_fields = ['max_levels', 'menu_items'] 21 | 22 | 23 | class SecondaryMenuItem(DjangoObjectType): 24 | class Meta: 25 | model = FlatMenuItem 26 | 27 | 28 | class SecondaryMenu(DjangoObjectType): 29 | class Meta: 30 | model = FlatMenu 31 | only_fields = ['title', 'handle', 'heading', 'max_levels', 'menu_items'] 32 | 33 | 34 | def MenusQueryMixin(): 35 | class Mixin: 36 | main_menu = graphene.List(Menu) 37 | secondary_menu = graphene.Field(SecondaryMenu, 38 | handle=graphene.String(required=True)) 39 | secondary_menus = graphene.List(SecondaryMenu) 40 | 41 | def resolve_main_menu(self, _info: ResolveInfo) -> List[MainMenu]: 42 | return MainMenu.objects.all() 43 | 44 | def resolve_secondary_menus(self, _info: ResolveInfo) -> List[FlatMenu]: 45 | return FlatMenu.objects.all() 46 | 47 | def resolve_secondary_menu(self, _info, handle: ResolveInfo) -> FlatMenu: 48 | return FlatMenu.objects.filter(handle=handle).first() 49 | return Mixin 50 | -------------------------------------------------------------------------------- /wagtail_graphql/types/settings.py: -------------------------------------------------------------------------------- 1 | # graphql 2 | from graphql.execution.base import ResolveInfo 3 | # graphene 4 | import graphene 5 | # graphene_django 6 | from graphene_django.converter import String 7 | # app 8 | from ..registry import registry 9 | from ..settings import settings_registry 10 | 11 | 12 | class Settings(graphene.Interface): 13 | __typename = graphene.Field(String) 14 | 15 | 16 | def SettingsQueryMixin(): 17 | class Mixin: 18 | if settings_registry: 19 | settings = graphene.Field(Settings, 20 | name=graphene.String(required=True)) 21 | 22 | def resolve_settings(self, _info: ResolveInfo, name): 23 | try: 24 | result = registry.settings[name][1].objects.first() 25 | except KeyError: 26 | raise ValueError(f"Settings '{name}' not found.") 27 | return result 28 | else: # pragma: no cover 29 | pass 30 | return Mixin 31 | -------------------------------------------------------------------------------- /wagtail_graphql/types/snippets.py: -------------------------------------------------------------------------------- 1 | # django 2 | from django.db import models 3 | # graphql 4 | from graphql.execution.base import ResolveInfo 5 | # graphene 6 | import graphene 7 | # app 8 | from ..registry import registry 9 | 10 | 11 | def SnippetsQueryMixin(): 12 | class Mixin: 13 | if registry.snippets: 14 | class Snippet(graphene.types.union.Union): 15 | class Meta: 16 | types = registry.snippets.types 17 | 18 | snippets = graphene.List(Snippet, 19 | typename=graphene.String(required=True)) 20 | 21 | def resolve_snippets(self, _info: ResolveInfo, typename: str) -> models.Model: 22 | node = registry.snippets_by_name[typename] 23 | cls = node._meta.model 24 | return cls.objects.all() 25 | else: # pragma: no cover 26 | pass 27 | return Mixin 28 | -------------------------------------------------------------------------------- /wagtail_graphql/types/streamfield.py: -------------------------------------------------------------------------------- 1 | # python 2 | from typing import Tuple, Callable 3 | import datetime 4 | # graphql 5 | from graphql import GraphQLScalarType 6 | from graphql.execution.base import ResolveInfo 7 | # graphene 8 | import graphene 9 | from graphene.utils.str_converters import to_snake_case 10 | from graphene.types.generic import GenericScalar 11 | from graphene.types import Scalar 12 | # graphene_django 13 | from graphene_django.converter import convert_django_field, List 14 | # dateutil 15 | from dateutil.parser import parse as dtparse 16 | # wagtail 17 | import wagtail.core.blocks 18 | import wagtail.images.blocks 19 | import wagtail.snippets.blocks 20 | from wagtail.core.blocks import Block, ListBlock, StructBlock 21 | from wagtail.core.fields import StreamField 22 | # app 23 | from ..registry import registry 24 | from .. import settings 25 | # app types 26 | from .core import Page, wagtailPage 27 | from .images import Image, wagtailImage 28 | 29 | # types 30 | StreamFieldHandlerType = Tuple[graphene.List, Callable[[StreamField, ResolveInfo], list]] 31 | 32 | 33 | @convert_django_field.register(StreamField) 34 | def convert_stream_field(field, _registry=None): 35 | return Scalar(description=field.help_text, required=not field.null) 36 | 37 | 38 | def _scalar_block(graphene_type): 39 | tp = registry.scalar_blocks.get(graphene_type) 40 | if not tp: 41 | node = '%sBlock' % graphene_type 42 | tp = type(node, (graphene.ObjectType,), { 43 | 'value': graphene.Field(graphene_type), 44 | 'field': graphene.Field(graphene.String), 45 | }) 46 | registry.scalar_blocks[graphene_type] = tp 47 | return tp 48 | 49 | 50 | def _resolve_scalar(key, type_): 51 | type_str = str(type_) 52 | if type_str == 'DateBlock': 53 | def resolve(self, _info: ResolveInfo): 54 | return type_(value=dtparse(self), field=key) 55 | elif type_str == 'DateTimeBlock': 56 | def resolve(self, _info: ResolveInfo): 57 | return type_(value=dtparse(self), field=key) 58 | elif type_str == 'TimeBlock': 59 | def resolve(self, _info: ResolveInfo): 60 | return type_(value=datetime.time.fromisoformat(self), field=key) 61 | else: 62 | def resolve(self, _info: ResolveInfo): 63 | return type_(value=self, field=key) 64 | return resolve 65 | 66 | 67 | def _page_block(): 68 | tp = registry.scalar_blocks.get(Page) 69 | if not tp: 70 | node = 'PageBlock' 71 | tp = type(node, (graphene.ObjectType,), { 72 | 'value': graphene.Field(Page), 73 | 'field': graphene.Field(graphene.String), 74 | }) 75 | registry.scalar_blocks[Page] = tp 76 | return tp 77 | 78 | 79 | def _resolve_page_block(key, type_): 80 | def resolve(self, info: ResolveInfo): 81 | return type_(value=_resolve_page(self, info), field=key) 82 | return resolve 83 | 84 | 85 | def _image_block(): 86 | tp = registry.scalar_blocks.get(Image) 87 | if not tp: 88 | node = 'ImageBlock' 89 | tp = type(node, (graphene.ObjectType,), { 90 | 'value': graphene.Field(Image), 91 | 'field': graphene.Field(graphene.String), 92 | }) 93 | registry.scalar_blocks[Image] = tp 94 | return tp 95 | 96 | 97 | def _resolve_image_block(key, type_): 98 | def resolve(self, info: ResolveInfo): 99 | return type_(value=_resolve_image(self, info), field=key) 100 | return resolve 101 | 102 | 103 | def _snippet_block(typ): 104 | tp = registry.scalar_blocks.get(typ) 105 | if not tp: 106 | node = '%sBlock' % typ 107 | tp = type(node, (graphene.ObjectType,), { 108 | 'value': graphene.Field(typ), 109 | 'field': graphene.Field(graphene.String), 110 | }) 111 | registry.scalar_blocks[typ] = tp 112 | return tp 113 | 114 | 115 | def _resolve_snippet_block(key, type_, snippet_type): 116 | def resolve(self, info: ResolveInfo): 117 | info.return_type = snippet_type 118 | return type_(value=_resolve_snippet(self, info), field=key) 119 | return resolve 120 | 121 | 122 | def _list_block(typ): 123 | tp = registry.scalar_blocks.get((List, typ)) 124 | if not tp: 125 | node = '%sListBlock' % typ 126 | tp = type(node, (graphene.ObjectType,), { 127 | 'value': graphene.List(typ), 128 | 'field': graphene.Field(graphene.String), 129 | }) 130 | registry.scalar_blocks[(List, typ)] = tp 131 | return tp 132 | 133 | 134 | def _resolve_list_block_scalar(key, type_, of_type): 135 | type_str = str(of_type) 136 | if type_str == 'Date' or type_str == 'DateTime': 137 | def resolve(self, _info: ResolveInfo): 138 | return type_(value=list(dtparse(s) for s in self), field=key) 139 | elif type_str == 'Time': 140 | def resolve(self, _info: ResolveInfo): 141 | return type_(value=list(datetime.time.fromisoformat(s) for s in self), field=key) 142 | else: 143 | def resolve(self, _info: ResolveInfo): 144 | return type_(value=list(s for s in self), field=key) 145 | return resolve 146 | 147 | 148 | def _resolve_list_block(key, type_, of_type): 149 | if issubclass(of_type, Scalar): 150 | resolve = _resolve_list_block_scalar(key, type_, of_type) 151 | elif of_type == Image: 152 | def resolve(self, info: ResolveInfo): 153 | return type_(value=list(_resolve_image(s, info) for s in self), field=key) 154 | elif of_type == Page: 155 | def resolve(self, info: ResolveInfo): 156 | return type_(value=list(_resolve_page(s, info) for s in self), field=key) 157 | elif of_type in registry.snippets.values(): 158 | def resolve(self, info: ResolveInfo): 159 | info.return_type = of_type 160 | return type_(value=list(_resolve_snippet(s, info) for s in self), field=key) 161 | else: 162 | def resolve(self, info: ResolveInfo): 163 | info.return_type = of_type 164 | return type_(value=list(of_type(**s) for s in self), field=key) 165 | return resolve 166 | 167 | 168 | def _create_root_blocks(block_type_handlers: dict): 169 | for k, t in block_type_handlers.items(): 170 | if not isinstance(t, tuple) and issubclass(t, Scalar): 171 | typ = _scalar_block(t) 172 | block_type_handlers[k] = typ, _resolve_scalar(k, typ) 173 | elif isinstance(t, tuple) and isinstance(t[0], List): 174 | typ = _list_block(t[0].of_type) 175 | block_type_handlers[k] = typ, _resolve_list_block(k, typ, t[0].of_type) 176 | elif isinstance(t, tuple) and issubclass(t[0], Page): 177 | typ = _page_block() 178 | block_type_handlers[k] = typ, _resolve_page_block(k, typ) 179 | elif isinstance(t, tuple) and issubclass(t[0], Image): 180 | typ = _image_block() 181 | block_type_handlers[k] = typ, _resolve_image_block(k, typ) 182 | elif isinstance(t, tuple) and t[0] in registry.snippets.values(): 183 | typ = _snippet_block(t[0]) 184 | block_type_handlers[k] = typ, _resolve_snippet_block(k, typ, t[0]) 185 | 186 | 187 | def convert_block(block, block_type_handlers: dict, info: ResolveInfo, is_lazy=True): 188 | if is_lazy: 189 | block_type = block.get('type') 190 | value = block.get('value') 191 | else: 192 | block_type, value = block[:2] 193 | if block_type in block_type_handlers: 194 | handler = block_type_handlers[block_type] 195 | if isinstance(handler, tuple): 196 | tp, resolver = handler 197 | return resolver(value, info) 198 | else: 199 | if isinstance(value, dict): 200 | return handler(**value) 201 | else: 202 | raise NotImplementedError() # pragma: no cover 203 | else: 204 | raise NotImplementedError() # pragma: no cover 205 | 206 | 207 | def _resolve_type(self, _info: ResolveInfo): 208 | return self.__class__ 209 | 210 | 211 | def stream_field_handler(stream_field_name: str, field_name: str, block_type_handlers: dict) -> StreamFieldHandlerType: 212 | # add Generic Scalars (default) 213 | if settings.LOAD_GENERIC_SCALARS: 214 | _scalar_block(GenericScalar) 215 | 216 | # Unions must reference NamedTypes, so for scalar types we need to create a new type to 217 | # encapsulate scalars, page links, images, snippets 218 | _create_root_blocks(block_type_handlers) 219 | 220 | types_ = list(block_type_handlers.values()) 221 | for i, t in enumerate(types_): 222 | if isinstance(t, tuple): 223 | types_[i] = t[0] 224 | 225 | class Meta: 226 | types = tuple(set(types_)) 227 | 228 | stream_field_type = type( 229 | stream_field_name + "Type", 230 | (graphene.Union, ), 231 | { 232 | 'Meta': Meta, 233 | 'resolve_type': _resolve_type 234 | } 235 | ) 236 | 237 | def resolve_field(self, info: ResolveInfo): 238 | field = getattr(self, field_name) 239 | return [convert_block(block, block_type_handlers, info, field.is_lazy) for block in field.stream_data] 240 | 241 | return graphene.List(stream_field_type), resolve_field 242 | 243 | 244 | def _is_compound_block(block): 245 | return isinstance(block, StructBlock) 246 | 247 | 248 | def _is_list_block(block): 249 | return isinstance(block, ListBlock) 250 | 251 | 252 | def _is_custom_type(block): 253 | return hasattr(block, "__graphql_type__") 254 | 255 | 256 | def _add_handler_resolves(dict_params): 257 | to_add = {} 258 | for k, v in dict_params.items(): 259 | if k == 'field': # pragma: no cover 260 | raise ValueError("StructBlocks cannot have fields named 'field'") 261 | if isinstance(v, tuple): 262 | val = v[0] 263 | to_add['resolve_' + k] = v[1] 264 | elif issubclass(v, (graphene.types.DateTime, graphene.types.Date)): 265 | val = v 266 | to_add['resolve_' + k] = _resolve_datetime 267 | elif issubclass(v, graphene.types.Time): 268 | val = v 269 | to_add['resolve_' + k] = _resolve_time 270 | elif not issubclass(v, Scalar): 271 | val = v 272 | to_add['resolve_' + k] = _resolve 273 | else: 274 | val = v 275 | dict_params[k] = graphene.Field(val) 276 | dict_params.update(to_add) 277 | 278 | 279 | def block_handler(block: Block, app, prefix=''): 280 | cls = block.__class__ 281 | handler = registry.blocks.get(cls) 282 | 283 | if handler is None: 284 | if _is_custom_type(block): 285 | target_block_type = block.__graphql_type__() 286 | this_handler = block_handler(target_block_type, app, prefix) 287 | if isinstance(this_handler, tuple): 288 | raise NotImplementedError() 289 | if hasattr(block, '__graphql_resolve__'): 290 | resolver = _resolve_custom(block, this_handler) 291 | elif issubclass(target_block_type, Scalar): 292 | resolver = _resolve_generic_scalar 293 | else: 294 | raise TypeError("Non Scalar custom types need an explicit __graphql_resolve__ method.") 295 | handler = (lambda x: this_handler, resolver) 296 | elif _is_compound_block(block): 297 | node = prefix + cls.__name__ 298 | dict_params = dict( 299 | (n, block_handler(block_type, app, prefix)) 300 | for n, block_type in block.child_blocks.items() 301 | ) 302 | _add_handler_resolves(dict_params) 303 | dict_params.update({ # add the field name 304 | 'field': graphene.Field(graphene.String), 305 | 'resolve_field': lambda *x: block.name, 306 | }) 307 | tp = type(node, (graphene.ObjectType,), dict_params) 308 | handler = tp 309 | registry.blocks[cls] = handler 310 | elif _is_list_block(block): 311 | this_handler = block_handler(block.child_block, app, prefix) 312 | if isinstance(this_handler, tuple): 313 | handler = List(this_handler[0]), _resolve_list(*this_handler) 314 | else: 315 | handler = List(this_handler), _resolve_simple_list 316 | else: 317 | handler = GenericScalar 318 | 319 | if cls == wagtail.snippets.blocks.SnippetChooserBlock: 320 | handler = (handler[0](block), handler[1]) # type: ignore 321 | 322 | return handler 323 | 324 | 325 | def _snippet_handler(block): 326 | tp = registry.snippets[block.target_model] 327 | return tp 328 | 329 | 330 | def _resolve_snippet(self, info: ResolveInfo): 331 | if self is None: 332 | return None 333 | field = to_snake_case(info.field_name) 334 | id_ = self if isinstance(self, int) else getattr(self, field) 335 | if hasattr(info.return_type, 'graphene_type'): 336 | cls = info.return_type.graphene_type._meta.model 337 | else: 338 | cls = info.return_type._meta.model 339 | obj = cls.objects.filter(id=id_).first() 340 | return obj 341 | 342 | 343 | def _resolve_image(self, info: ResolveInfo): 344 | if self is None: 345 | return None 346 | field = to_snake_case(info.field_name) 347 | id_ = self if isinstance(self, int) else getattr(self, field) 348 | return wagtailImage.objects.filter(id=id_).first() 349 | 350 | 351 | def _resolve_page(self, info: ResolveInfo): 352 | if self is None: 353 | return None 354 | field = to_snake_case(info.field_name) 355 | id_ = self if isinstance(self, int) else getattr(self, field) 356 | return wagtailPage.objects.filter(id=id_).specific().first() 357 | 358 | 359 | def _resolve(self, info: ResolveInfo): 360 | if self is None: 361 | return None 362 | field = to_snake_case(info.field_name) 363 | data = getattr(self, field) 364 | cls = info.return_type 365 | return cls.graphene_type(**data) 366 | 367 | 368 | def _resolve_datetime(self, info: ResolveInfo): 369 | if self is None: 370 | return None 371 | field = to_snake_case(info.field_name) 372 | data = getattr(self, field) 373 | return dtparse(data) if data else None 374 | 375 | 376 | def _resolve_time(self, info: ResolveInfo): 377 | if self is None: 378 | return None 379 | field = to_snake_case(info.field_name) 380 | data = getattr(self, field) 381 | return datetime.time.fromisoformat(data) if data else None 382 | 383 | 384 | def _resolve_custom(block, hdl): 385 | def _inner(self, info: ResolveInfo): 386 | if self is None: 387 | return None 388 | cls = info.return_type 389 | if isinstance(self, dict): 390 | data = self 391 | else: 392 | data = getattr(self, info.field_name) 393 | value = block.__graphql_resolve__(data, info) 394 | 395 | if hasattr(cls, "serialize"): 396 | return cls.serialize(value) 397 | return hdl(**value) 398 | return _inner 399 | 400 | 401 | def _resolve_generic_scalar(self, info: ResolveInfo): 402 | if self is None: 403 | return None 404 | data = getattr(self, info.field_name) 405 | cls = info.return_type 406 | return cls.serialize(data) 407 | 408 | 409 | def _resolve_simple_list(self, info: ResolveInfo): 410 | if self is None: 411 | return None 412 | field = to_snake_case(info.field_name) 413 | data = getattr(self, field) 414 | cls = info.return_type.of_type 415 | if isinstance(cls, (Scalar, GraphQLScalarType)): 416 | return list(d for d in data) 417 | return list(cls(**d) for d in data) 418 | 419 | 420 | def _resolve_list(tp, inner_resolver): 421 | def resolve(self, info: ResolveInfo): 422 | if self is None: 423 | return None 424 | field = to_snake_case(info.field_name) 425 | ids = getattr(self, field) 426 | info.return_type = tp 427 | return list(inner_resolver(i, info) for i in ids) 428 | return resolve 429 | 430 | 431 | registry.blocks.update({ 432 | # choosers 433 | wagtail.images.blocks.ImageChooserBlock: (Image, _resolve_image), 434 | wagtail.core.blocks.PageChooserBlock: (Page, _resolve_page), 435 | wagtail.snippets.blocks.SnippetChooserBlock: (_snippet_handler, _resolve_snippet), 436 | # standard fields 437 | wagtail.core.blocks.CharBlock: graphene.types.String, 438 | wagtail.core.blocks.URLBlock: graphene.types.String, 439 | wagtail.core.blocks.DateBlock: graphene.types.Date, 440 | wagtail.core.blocks.DateTimeBlock: graphene.types.DateTime, 441 | wagtail.core.blocks.BooleanBlock: graphene.types.Boolean, 442 | wagtail.core.blocks.IntegerBlock: graphene.types.Int, 443 | wagtail.core.blocks.FloatBlock: graphene.types.Float, 444 | wagtail.core.blocks.DecimalBlock: graphene.types.String, 445 | wagtail.core.blocks.TextBlock: graphene.types.String, 446 | wagtail.core.blocks.TimeBlock: graphene.types.Time, 447 | wagtail.core.blocks.RichTextBlock: graphene.types.String, 448 | wagtail.core.blocks.RawHTMLBlock: graphene.types.String, 449 | wagtail.core.blocks.BlockQuoteBlock: graphene.types.String, 450 | wagtail.core.blocks.ChoiceBlock: graphene.types.String, 451 | wagtail.core.blocks.RegexBlock: graphene.types.String, 452 | wagtail.core.blocks.EmailBlock: graphene.types.String, 453 | wagtail.core.blocks.StaticBlock: graphene.types.String, 454 | }) 455 | --------------------------------------------------------------------------------