Merge branch 'main' into fix-m2m-filter-type

This commit is contained in:
Firas Kafri 2023-05-04 22:08:38 +03:00 committed by GitHub
commit c2901d682a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 269 additions and 316 deletions

View File

@ -10,17 +10,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python 3.9 - name: Set up Python 3.11
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: 3.9 python-version: '3.11'
- name: Build wheel and source tarball - name: Build wheel and source tarball
run: | run: |
pip install wheel pip install wheel
python setup.py sdist bdist_wheel python setup.py sdist bdist_wheel
- name: Publish a Python distribution to PyPI - name: Publish a Python distribution to PyPI
uses: pypa/gh-action-pypi-publish@v1.1.0 uses: pypa/gh-action-pypi-publish@v1.8.6
with: with:
user: __token__ user: __token__
password: ${{ secrets.pypi_password }} password: ${{ secrets.pypi_password }}

View File

@ -7,11 +7,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python 3.9 - name: Set up Python 3.11
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: 3.9 python-version: '3.11'
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip

View File

@ -13,10 +13,12 @@ jobs:
include: include:
- django: "3.2" - django: "3.2"
python-version: "3.7" python-version: "3.7"
- django: "4.1"
python-version: "3.11"
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies

View File

@ -1,8 +1,8 @@
default_language_version: default_language_version:
python: python3.9 python: python3.11
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0 rev: v4.4.0
hooks: hooks:
- id: check-merge-conflict - id: check-merge-conflict
- id: check-json - id: check-json
@ -16,15 +16,15 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
exclude: README.md exclude: README.md
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.2.0 rev: v3.3.2
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py37-plus] args: [--py37-plus]
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 22.10.0 rev: 23.3.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: 5.0.4 rev: 6.0.0
hooks: hooks:
- id: flake8 - id: flake8

View File

@ -6,6 +6,7 @@ help:
.PHONY: dev-setup ## Install development dependencies .PHONY: dev-setup ## Install development dependencies
dev-setup: dev-setup:
pip install -e ".[dev]" pip install -e ".[dev]"
python -m pre_commit install
.PHONY: tests ## Run unit tests .PHONY: tests ## Run unit tests
tests: tests:

View File

@ -2,8 +2,8 @@ Filtering
========= =========
Graphene integrates with Graphene integrates with
`django-filter <https://django-filter.readthedocs.io/en/main/>`__ to provide filtering of results. `django-filter <https://django-filter.readthedocs.io/en/stable/>`__ to provide filtering of results.
See the `usage documentation <https://django-filter.readthedocs.io/en/main/guide/usage.html#the-filter>`__ See the `usage documentation <https://django-filter.readthedocs.io/en/stable/guide/usage.html#the-filter>`__
for details on the format for ``filter_fields``. for details on the format for ``filter_fields``.
This filtering is automatically available when implementing a ``relay.Node``. This filtering is automatically available when implementing a ``relay.Node``.

View File

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [] dependencies = []

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("ingredients", "0001_initial"), ("ingredients", "0001_initial"),
] ]

View File

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("ingredients", "0002_auto_20161104_0050"), ("ingredients", "0002_auto_20161104_0050"),
] ]

View File

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("recipes", "0001_initial"), ("recipes", "0001_initial"),
] ]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("recipes", "0002_auto_20161104_0106"), ("recipes", "0002_auto_20161104_0106"),
] ]

View File

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [] dependencies = []

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("ingredients", "0001_initial"), ("ingredients", "0001_initial"),
] ]

View File

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("recipes", "0001_initial"), ("recipes", "0001_initial"),
] ]

View File

@ -1,7 +1,7 @@
from .fields import DjangoConnectionField, DjangoListField from .fields import DjangoConnectionField, DjangoListField
from .types import DjangoObjectType from .types import DjangoObjectType
__version__ = "3.0.0" __version__ = "3.0.2"
__all__ = [ __all__ = [
"__version__", "__version__",

View File

@ -96,7 +96,12 @@ def convert_choices_to_named_enum_with_descriptions(name, choices):
def description(self): def description(self):
return str(named_choices_descriptions[self.name]) return str(named_choices_descriptions[self.name])
return_type = Enum(name, list(named_choices), type=EnumWithDescriptionsType) return_type = Enum(
name,
list(named_choices),
type=EnumWithDescriptionsType,
description="An enumeration.", # Temporary fix until https://github.com/graphql-python/graphene/pull/1502 is merged
)
return return_type return return_type
@ -315,26 +320,7 @@ def convert_field_to_djangomodel(field, registry=None):
if not _type: if not _type:
return return
class CustomField(Field): return Field(
def wrap_resolve(self, parent_resolver):
"""
Implements a custom resolver which go through the `get_node` method to ensure that
it goes through the `get_queryset` method of the DjangoObjectType.
"""
resolver = super().wrap_resolve(parent_resolver)
def custom_resolver(root, info, **args):
fk_obj = resolver(root, info, **args)
if not isinstance(fk_obj, model):
# In case the resolver is a custom one that overwrites
# the default Django resolver
# This happens, for example, when using custom awaitable resolvers.
return fk_obj
return _type.get_node(info, fk_obj.pk)
return custom_resolver
return CustomField(
_type, _type,
description=get_django_field_description(field), description=get_django_field_description(field),
required=not field.null, required=not field.null,

View File

@ -87,7 +87,6 @@ def Query(EventType):
events = DjangoFilterConnectionField(EventType) events = DjangoFilterConnectionField(EventType)
def resolve_events(self, info, **kwargs): def resolve_events(self, info, **kwargs):
events = [ events = [
Event(name="Live Show", tags=["concert", "music", "rock"]), Event(name="Live Show", tags=["concert", "music", "rock"]),
Event(name="Musical", tags=["movie", "music"]), Event(name="Musical", tags=["movie", "music"]),

View File

@ -82,7 +82,6 @@ class DjangoFormMutation(BaseDjangoFormMutation):
def __init_subclass_with_meta__( def __init_subclass_with_meta__(
cls, form_class=None, only_fields=(), exclude_fields=(), **options cls, form_class=None, only_fields=(), exclude_fields=(), **options
): ):
if not form_class: if not form_class:
raise Exception("form_class is required for DjangoFormMutation") raise Exception("form_class is required for DjangoFormMutation")
@ -129,7 +128,6 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
exclude_fields=(), exclude_fields=(),
**options, **options,
): ):
if not form_class: if not form_class:
raise Exception("form_class is required for DjangoModelFormMutation") raise Exception("form_class is required for DjangoModelFormMutation")

View File

@ -63,7 +63,7 @@ class Command(CommandArguments):
if out == "-" or out == "-.json": if out == "-" or out == "-.json":
self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
elif out == "-.graphql": elif out == "-.graphql":
self.stdout.write(print_schema(schema)) self.stdout.write(print_schema(schema.graphql_schema))
else: else:
# Determine format # Determine format
_, file_extension = os.path.splitext(out) _, file_extension = os.path.splitext(out)

View File

@ -72,7 +72,6 @@ class SerializerMutation(ClientIDMutation):
_meta=None, _meta=None,
**options **options
): ):
if not serializer_class: if not serializer_class:
raise Exception("serializer_class is required for the SerializerMutation") raise Exception("serializer_class is required for the SerializerMutation")

View File

@ -5,7 +5,8 @@
GraphiQL, GraphiQL,
React, React,
ReactDOM, ReactDOM,
SubscriptionsTransportWs, graphqlWs,
GraphiQLPluginExplorer,
fetch, fetch,
history, history,
location, location,
@ -52,108 +53,34 @@
var fetchURL = locationQuery(otherParams); var fetchURL = locationQuery(otherParams);
// Defines a GraphQL fetcher using the fetch API.
function httpClient(graphQLParams, opts) {
if (typeof opts === 'undefined') {
opts = {};
}
var headers = opts.headers || {};
headers['Accept'] = headers['Accept'] || 'application/json';
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
// Parse the cookie value for a CSRF token
var csrftoken;
var cookies = ("; " + document.cookie).split("; csrftoken=");
if (cookies.length == 2) {
csrftoken = cookies.pop().split(";").shift();
} else {
csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value;
}
if (csrftoken) {
headers['X-CSRFToken'] = csrftoken
}
return fetch(fetchURL, {
method: "post",
headers: headers,
body: JSON.stringify(graphQLParams),
credentials: "include",
})
.then(function (response) {
return response.text();
})
.then(function (responseBody) {
try {
return JSON.parse(responseBody);
} catch (error) {
return responseBody;
}
});
}
// Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise // Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise
// assumes the current window location with an appropriate websocket protocol. // assumes the current window location with an appropriate websocket protocol.
var subscribeURL = var subscribeURL =
location.origin.replace(/^http/, "ws") + location.origin.replace(/^http/, "ws") +
(GRAPHENE_SETTINGS.subscriptionPath || location.pathname); (GRAPHENE_SETTINGS.subscriptionPath || location.pathname);
// Create a subscription client. function trueLambda() { return true; };
var subscriptionClient = new SubscriptionsTransportWs.SubscriptionClient(
subscribeURL, var headers = {};
{ var cookies = ("; " + document.cookie).split("; csrftoken=");
// Reconnect after any interruptions. if (cookies.length == 2) {
reconnect: true, csrftoken = cookies.pop().split(";").shift();
// Delay socket initialization until the first subscription is started. } else {
csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value;
}
if (csrftoken) {
headers['X-CSRFToken'] = csrftoken
}
var graphQLFetcher = GraphiQL.createFetcher({
url: fetchURL,
wsClient: graphqlWs.createClient({
url: subscribeURL,
shouldRetry: trueLambda,
lazy: true, lazy: true,
}, }),
); headers: headers
})
// Keep a reference to the currently-active subscription, if available.
var activeSubscription = null;
// Define a GraphQL fetcher that can intelligently route queries based on the operation type.
function graphQLFetcher(graphQLParams, opts) {
var operationType = getOperationType(graphQLParams);
// If we're about to execute a new operation, and we have an active subscription,
// unsubscribe before continuing.
if (activeSubscription) {
activeSubscription.unsubscribe();
activeSubscription = null;
}
if (operationType === "subscription") {
return {
subscribe: function (observer) {
activeSubscription = subscriptionClient;
return subscriptionClient.request(graphQLParams, opts).subscribe(observer);
},
};
} else {
return httpClient(graphQLParams, opts);
}
}
// Determine the type of operation being executed for a given set of GraphQL parameters.
function getOperationType(graphQLParams) {
// Run a regex against the query to determine the operation type (query, mutation, subscription).
var operationRegex = new RegExp(
// Look for lines that start with an operation keyword, ignoring whitespace.
"^\\s*(query|mutation|subscription)\\s*" +
// The operation keyword should be followed by whitespace and the operationName in the GraphQL parameters (if available).
(graphQLParams.operationName ? ("\\s+" + graphQLParams.operationName) : "") +
// The line should eventually encounter an opening curly brace.
"[^\\{]*\\{",
// Enable multiline matching.
"m",
);
var match = operationRegex.exec(graphQLParams.query);
if (!match) {
return "query";
}
return match[1];
}
// When the query and variables string is edited, update the URL bar so // When the query and variables string is edited, update the URL bar so
// that it can be easily shared. // that it can be easily shared.
@ -172,24 +99,44 @@
function updateURL() { function updateURL() {
history.replaceState(null, null, locationQuery(parameters)); history.replaceState(null, null, locationQuery(parameters));
} }
var options = {
fetcher: graphQLFetcher, function GraphiQLWithExplorer() {
onEditQuery: onEditQuery, var [query, setQuery] = React.useState(parameters.query);
onEditVariables: onEditVariables,
onEditOperationName: onEditOperationName, function handleQuery(query) {
headerEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled, setQuery(query);
shouldPersistHeaders: GRAPHENE_SETTINGS.graphiqlShouldPersistHeaders, onEditQuery(query);
query: parameters.query, }
};
if (parameters.variables) { var explorerPlugin = GraphiQLPluginExplorer.useExplorerPlugin({
options.variables = parameters.variables; query: query,
} onEdit: handleQuery,
if (parameters.operation_name) { });
options.operationName = parameters.operation_name;
var options = {
fetcher: graphQLFetcher,
plugins: [explorerPlugin],
defaultEditorToolsVisibility: true,
onEditQuery: handleQuery,
onEditVariables: onEditVariables,
onEditOperationName: onEditOperationName,
isHeadersEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled,
shouldPersistHeaders: GRAPHENE_SETTINGS.graphiqlShouldPersistHeaders,
query: query,
};
if (parameters.variables) {
options.variables = parameters.variables;
}
if (parameters.operation_name) {
options.operationName = parameters.operation_name;
}
return React.createElement(GraphiQL, options);
} }
// Render <GraphiQL /> into the body. // Render <GraphiQL /> into the body.
ReactDOM.render( ReactDOM.render(
React.createElement(GraphiQL, options), React.createElement(GraphiQLWithExplorer),
document.getElementById("editor"), document.getElementById("editor"),
); );
})( })(
@ -199,7 +146,8 @@
window.GraphiQL, window.GraphiQL,
window.React, window.React,
window.ReactDOM, window.ReactDOM,
window.SubscriptionsTransportWs, window.graphqlWs,
window.GraphiQLPluginExplorer,
window.fetch, window.fetch,
window.history, window.history,
window.location, window.location,

View File

@ -33,9 +33,12 @@ add "&raw" to the end of the URL within a browser.
<script src="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js" <script src="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js"
integrity="{{graphiql_sri}}" integrity="{{graphiql_sri}}"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/subscriptions-transport-ws@{{subscriptions_transport_ws_version}}/browser/client.js" <script src="https://cdn.jsdelivr.net/npm/graphql-ws@{{subscriptions_transport_ws_version}}/umd/graphql-ws.min.js"
integrity="{{subscriptions_transport_ws_sri}}" integrity="{{subscriptions_transport_ws_sri}}"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@graphiql/plugin-explorer@{{graphiql_plugin_explorer_version}}/dist/graphiql-plugin-explorer.umd.js"
integrity="{{graphiql_plugin_explorer_sri}}"
crossorigin="anonymous"></script>
</head> </head>
<body> <body>
<div id="editor"></div> <div id="editor"></div>

View File

@ -5,7 +5,6 @@ from .mutations import PetFormMutation, PetMutation
class QueryRoot(ObjectType): class QueryRoot(ObjectType):
thrower = graphene.String(required=True) thrower = graphene.String(required=True)
request = graphene.String(required=True) request = graphene.String(required=True)
test = graphene.String(who=graphene.String()) test = graphene.String(who=graphene.String())

View File

@ -1,5 +1,6 @@
import datetime import datetime
from django.db.models import Count import re
from django.db.models import Count, Prefetch
import pytest import pytest
@ -7,8 +8,12 @@ from graphene import List, NonNull, ObjectType, Schema, String
from ..fields import DjangoListField from ..fields import DjangoListField
from ..types import DjangoObjectType from ..types import DjangoObjectType
from .models import Article as ArticleModel from .models import (
from .models import Reporter as ReporterModel Article as ArticleModel,
Film as FilmModel,
FilmDetails as FilmDetailsModel,
Reporter as ReporterModel,
)
class TestDjangoListField: class TestDjangoListField:
@ -500,3 +505,145 @@ class TestDjangoListField:
assert not result.errors assert not result.errors
assert result.data == {"reporters": [{"firstName": "Tara"}]} assert result.data == {"reporters": [{"firstName": "Tara"}]}
def test_select_related_and_prefetch_related_are_respected(
self, django_assert_num_queries
):
class Article(DjangoObjectType):
class Meta:
model = ArticleModel
fields = ("headline", "editor", "reporter")
class Film(DjangoObjectType):
class Meta:
model = FilmModel
fields = ("genre", "details")
class FilmDetail(DjangoObjectType):
class Meta:
model = FilmDetailsModel
fields = ("location",)
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ("first_name", "articles", "films")
class Query(ObjectType):
articles = DjangoListField(Article)
@staticmethod
def resolve_articles(root, info):
# Optimize for querying associated editors and reporters, and the films and film
# details of those reporters. This is similar to what would happen using a library
# like https://github.com/tfoxy/graphene-django-optimizer for a query like the one
# below (albeit simplified and hardcoded here).
return ArticleModel.objects.select_related(
"editor", "reporter"
).prefetch_related(
Prefetch(
"reporter__films",
queryset=FilmModel.objects.select_related("details"),
),
)
schema = Schema(query=Query)
query = """
query {
articles {
headline
editor {
firstName
}
reporter {
firstName
films {
genre
details {
location
}
}
}
}
}
"""
r1 = ReporterModel.objects.create(first_name="Tara", last_name="West")
r2 = ReporterModel.objects.create(first_name="Debra", last_name="Payne")
ArticleModel.objects.create(
headline="Amazing news",
reporter=r1,
pub_date=datetime.date.today(),
pub_date_time=datetime.datetime.now(),
editor=r2,
)
ArticleModel.objects.create(
headline="Not so good news",
reporter=r2,
pub_date=datetime.date.today(),
pub_date_time=datetime.datetime.now(),
editor=r1,
)
film1 = FilmModel.objects.create(genre="ac")
film2 = FilmModel.objects.create(genre="ot")
film3 = FilmModel.objects.create(genre="do")
FilmDetailsModel.objects.create(location="Hollywood", film=film1)
FilmDetailsModel.objects.create(location="Antarctica", film=film3)
r1.films.add(film1, film2)
r2.films.add(film3)
# We expect 2 queries to be performed based on the above resolver definition: one for all
# articles joined with the reporters model (for associated editors and reporters), and one
# for the films prefetch (which includes its `select_related` JOIN logic in its queryset)
with django_assert_num_queries(2) as captured:
result = schema.execute(query)
assert not result.errors
assert result.data == {
"articles": [
{
"headline": "Amazing news",
"editor": {"firstName": "Debra"},
"reporter": {
"firstName": "Tara",
"films": [
{"genre": "AC", "details": {"location": "Hollywood"}},
{"genre": "OT", "details": None},
],
},
},
{
"headline": "Not so good news",
"editor": {"firstName": "Tara"},
"reporter": {
"firstName": "Debra",
"films": [
{"genre": "DO", "details": {"location": "Antarctica"}},
],
},
},
]
}
assert len(captured.captured_queries) == 2 # Sanity-check
# First we should have queried for all articles in a single query, joining on the reporters
# model (for the editors and reporters ForeignKeys)
assert re.match(
r'SELECT .* "tests_article" INNER JOIN "tests_reporter"',
captured.captured_queries[0]["sql"],
)
# Then we should have queried for all of the films of all reporters, joined with the film
# details for each film, using a single query
assert re.match(
r'SELECT .* FROM "tests_film" INNER JOIN "tests_film_reporters" .* LEFT OUTER JOIN "tests_filmdetails"',
captured.captured_queries[1]["sql"],
)

View File

@ -16,6 +16,12 @@ class TestShouldCallGetQuerySetOnForeignKey:
Check that the get_queryset method is called in both forward and reversed direction Check that the get_queryset method is called in both forward and reversed direction
of a foreignkey on types. of a foreignkey on types.
(see issue #1111) (see issue #1111)
NOTE: For now, we do not expect this get_queryset method to be called for nested
objects, as the original attempt to do so prevented SQL query-optimization with
`select_related`/`prefetch_related` and caused N+1 queries. See discussions here
https://github.com/graphql-python/graphene-django/pull/1315/files#r1015659857
and here https://github.com/graphql-python/graphene-django/pull/1401.
""" """
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -121,69 +127,6 @@ class TestShouldCallGetQuerySetOnForeignKey:
assert not result.errors assert not result.errors
assert result.data == {"reporter": {"firstName": "Jane"}} assert result.data == {"reporter": {"firstName": "Jane"}}
def test_get_queryset_called_on_foreignkey(self):
# If a user tries to access a reporter through an article they should get our authorization error
query = """
query getArticle($id: ID!) {
article(id: $id) {
headline
reporter {
firstName
}
}
}
"""
result = self.schema.execute(query, variables={"id": self.articles[0].id})
assert len(result.errors) == 1
assert result.errors[0].message == "Not authorized to access reporters."
# An admin user should be able to get reporters through an article
query = """
query getArticle($id: ID!) {
article(id: $id) {
headline
reporter {
firstName
}
}
}
"""
result = self.schema.execute(
query,
variables={"id": self.articles[0].id},
context_value={"admin": True},
)
assert not result.errors
assert result.data["article"] == {
"headline": "A fantastic article",
"reporter": {"firstName": "Jane"},
}
# An admin user should not be able to access draft article through a reporter
query = """
query getReporter($id: ID!) {
reporter(id: $id) {
firstName
articles {
headline
}
}
}
"""
result = self.schema.execute(
query,
variables={"id": self.reporter.id},
context_value={"admin": True},
)
assert not result.errors
assert result.data["reporter"] == {
"firstName": "Jane",
"articles": [{"headline": "A fantastic article"}],
}
class TestShouldCallGetQuerySetOnForeignKeyNode: class TestShouldCallGetQuerySetOnForeignKeyNode:
""" """
@ -290,72 +233,3 @@ class TestShouldCallGetQuerySetOnForeignKeyNode:
) )
assert not result.errors assert not result.errors
assert result.data == {"reporter": {"firstName": "Jane"}} assert result.data == {"reporter": {"firstName": "Jane"}}
def test_get_queryset_called_on_foreignkey(self):
# If a user tries to access a reporter through an article they should get our authorization error
query = """
query getArticle($id: ID!) {
article(id: $id) {
headline
reporter {
firstName
}
}
}
"""
result = self.schema.execute(
query, variables={"id": to_global_id("ArticleType", self.articles[0].id)}
)
assert len(result.errors) == 1
assert result.errors[0].message == "Not authorized to access reporters."
# An admin user should be able to get reporters through an article
query = """
query getArticle($id: ID!) {
article(id: $id) {
headline
reporter {
firstName
}
}
}
"""
result = self.schema.execute(
query,
variables={"id": to_global_id("ArticleType", self.articles[0].id)},
context_value={"admin": True},
)
assert not result.errors
assert result.data["article"] == {
"headline": "A fantastic article",
"reporter": {"firstName": "Jane"},
}
# An admin user should not be able to access draft article through a reporter
query = """
query getReporter($id: ID!) {
reporter(id: $id) {
firstName
articles {
edges {
node {
headline
}
}
}
}
}
"""
result = self.schema.execute(
query,
variables={"id": to_global_id("ReporterType", self.reporter.id)},
context_value={"admin": True},
)
assert not result.errors
assert result.data["reporter"] == {
"firstName": "Jane",
"articles": {"edges": [{"node": {"headline": "A fantastic article"}}]},
}

View File

@ -780,7 +780,6 @@ def test_should_query_promise_connectionfields():
def test_should_query_connectionfields_with_last(): def test_should_query_connectionfields_with_last():
r = Reporter.objects.create( r = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
) )
@ -818,7 +817,6 @@ def test_should_query_connectionfields_with_last():
def test_should_query_connectionfields_with_manager(): def test_should_query_connectionfields_with_manager():
r = Reporter.objects.create( r = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
) )

View File

@ -66,16 +66,19 @@ class GraphQLView(View):
react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0=" react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
# The GraphiQL React app. # The GraphiQL React app.
graphiql_version = "1.4.7" # "1.0.3" graphiql_version = "2.4.1" # "1.0.3"
graphiql_sri = "sha256-cpZ8w9D/i6XdEbY/Eu7yAXeYzReVw0mxYd7OU3gUcsc=" # "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk=" graphiql_sri = "sha256-s+f7CFAPSUIygFnRC2nfoiEKd3liCUy+snSdYFAoLUc=" # "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
graphiql_css_sri = "sha256-HADQowUuFum02+Ckkv5Yu5ygRoLllHZqg0TFZXY7NHI=" # "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E=" graphiql_css_sri = "sha256-88yn8FJMyGboGs4Bj+Pbb3kWOWXo7jmb+XCRHE+282k=" # "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
# The websocket transport library for subscriptions. # The websocket transport library for subscriptions.
subscriptions_transport_ws_version = "0.9.18" subscriptions_transport_ws_version = "5.12.1"
subscriptions_transport_ws_sri = ( subscriptions_transport_ws_sri = (
"sha256-i0hAXd4PdJ/cHX3/8tIy/Q/qKiWr5WSTxMFuL9tACkw=" "sha256-EZhvg6ANJrBsgLvLAa0uuHNLepLJVCFYS+xlb5U/bqw="
) )
graphiql_plugin_explorer_version = "0.1.15"
graphiql_plugin_explorer_sri = "sha256-3hUuhBXdXlfCj6RTeEkJFtEh/kUG+TCDASFpFPLrzvE="
schema = None schema = None
graphiql = False graphiql = False
middleware = None middleware = None
@ -158,6 +161,8 @@ class GraphQLView(View):
graphiql_css_sri=self.graphiql_css_sri, graphiql_css_sri=self.graphiql_css_sri,
subscriptions_transport_ws_version=self.subscriptions_transport_ws_version, subscriptions_transport_ws_version=self.subscriptions_transport_ws_version,
subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri, subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri,
graphiql_plugin_explorer_version=self.graphiql_plugin_explorer_version,
graphiql_plugin_explorer_sri=self.graphiql_plugin_explorer_sri,
# The SUBSCRIPTION_PATH setting. # The SUBSCRIPTION_PATH setting.
subscription_path=self.subscription_path, subscription_path=self.subscription_path,
# GraphiQL headers tab, # GraphiQL headers tab,

View File

@ -14,7 +14,7 @@ rest_framework_require = ["djangorestframework>=3.6.3"]
tests_require = [ tests_require = [
"pytest>=7.1.3", "pytest>=7.3.1",
"pytest-cov", "pytest-cov",
"pytest-random-order", "pytest-random-order",
"coveralls", "coveralls",
@ -26,10 +26,11 @@ tests_require = [
dev_requires = [ dev_requires = [
"black==22.8.0", "black==23.3.0",
"flake8==5.0.4", "flake8==6.0.0",
"flake8-black==0.3.3", "flake8-black==0.3.6",
"flake8-bugbear==22.9.11", "flake8-bugbear==23.3.23",
"pre-commit",
] + tests_require ] + tests_require
setup( setup(
@ -50,6 +51,7 @@ setup(
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: PyPy",
"Framework :: Django", "Framework :: Django",
"Framework :: Django :: 3.2", "Framework :: Django :: 3.2",

View File

@ -2,6 +2,7 @@
envlist = envlist =
py{37,38,39,310}-django32, py{37,38,39,310}-django32,
py{38,39,310}-django{40,41,main}, py{38,39,310}-django{40,41,main},
py311-django{41,main}
pre-commit pre-commit
[gh-actions] [gh-actions]
@ -10,6 +11,7 @@ python =
3.8: py38 3.8: py38
3.9: py39 3.9: py39
3.10: py310 3.10: py310
3.11: py311
[gh-actions:env] [gh-actions:env]
DJANGO = DJANGO =