diff --git a/aleksis/core/frontend/app/apollo.js b/aleksis/core/frontend/app/apollo.js index 4652c386e9b02f6cc2d8a9f27a6b78f3b8a41f48..97196fc79459ebdbf29da502d0cce2e9fd66da4e 100644 --- a/aleksis/core/frontend/app/apollo.js +++ b/aleksis/core/frontend/app/apollo.js @@ -6,6 +6,7 @@ import { ApolloClient, from, HttpLink } from "@/apollo-boost"; import { persistCache, LocalStorageWrapper } from "@/apollo3-cache-persist"; import { InMemoryCache } from "@/apollo-cache-inmemory"; +import createUploadLink from "@/apollo-upload-client/createUploadLink.mjs"; import errorCodes from "../errorCodes"; @@ -29,18 +30,12 @@ function getGraphqlURL() { return new URL(settings.urls.graphql, base); } -// Define Apollo links for handling query operations. -const links = [ - // HTTP link to the real backend (Django) - new HttpLink({ - uri: getGraphqlURL(), - }), -]; - /** Upstream Apollo GraphQL client */ const apolloClient = new ApolloClient({ cache, - link: from(links), + link: createUploadLink({ + uri: getGraphqlURL(), + }), }); const apolloOpts = { diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py index 6d0b519e9f5c2252e5e10ec21f53d4b6a6481cd5..ac94e25565446c047a41b1b6790618d0474f07cc 100644 --- a/aleksis/core/schema/person.py +++ b/aleksis/core/schema/person.py @@ -298,6 +298,8 @@ class PersonBatchCreateMutation(BaseBatchCreateMutation): "avatar", "guardians", "primary_group", + "photo", + "avatar", ) optional_fields = ( "additional_name", @@ -317,6 +319,8 @@ class PersonBatchCreateMutation(BaseBatchCreateMutation): "avatar", "guardians", "primary_group", + "photo", + "avatar", ) @@ -346,23 +350,27 @@ class PersonBatchPatchMutation(BaseBatchPatchMutation): "avatar", "guardians", "primary_group", + "photo", + "avatar", ) optional_fields = ( - "additional_name", - "short_name", - "street", - "housenumber", - "postal_code", - "place", - "phone_number", - "mobile_number", - "email", - "date_of_birth", - "place_of_birth", - "sex", - "description", - "photo", - "avatar", - "guardians", - "primary_group", - ) + "additional_name", + "short_name", + "street", + "housenumber", + "postal_code", + "place", + "phone_number", + "mobile_number", + "email", + "date_of_birth", + "place_of_birth", + "sex", + "description", + "photo", + "avatar", + "guardians", + "primary_group", + "photo", + "avatar", + ) diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 1915cb4e7048a7e1644009fc5fe8435bc7e33ab8..6fdb6de794a30234cfe6817e53d45f30b4f61bad 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -586,8 +586,9 @@ YARN_INSTALLED_APPS = [ "@iconify/iconify@^2.2.1", "@iconify/json@^2.1.30", "@mdi/font@^7.2.96", + "@apollo/client@^3.8.0", "apollo-boost@^0.4.9", - "apollo3-cache-persist@^0.14.1", + "apollo3-cache-persist@^0.15.0", "deepmerge@^4.2.2", "graphql@^15.8.0", "graphql-tag@^2.12.6", diff --git a/aleksis/core/tests/schema/test_persons.py b/aleksis/core/tests/schema/test_persons.py index 06f7de81c7127dd9d0817d1e6e02dcc7107a0472..420d9882f367a9f9b796c136dc5d60a83da53d8c 100644 --- a/aleksis/core/tests/schema/test_persons.py +++ b/aleksis/core/tests/schema/test_persons.py @@ -90,3 +90,136 @@ def test_persons_query(client_query): variables={"id": wrong_member.id}, ) assert content["data"]["object"] is None + + +def test_create_person_with_file_upload(client_query, uploaded_picture): + p = Person.objects.first() + global_permission = Permission.objects.get( + codename="add_person", content_type__app_label="core" + ) + p.user.user_permissions.add(global_permission) + + query = """ + mutation createPersons($input: [BatchCreatePersonInput]!) { + createPersons(input: $input) { + items: persons { + id + firstName + lastName + } + } + } + """ + variables = { + "input": [ + { + "firstName": "Foo", + "lastName": "Bar", + "photo": None, + "avatar": None, + }, + ] + } + files = { + "variables.input.0.photo": uploaded_picture, + "variables.input.0.avatar": uploaded_picture, + } + + response, content = client_query(query, variables=variables, files=files) + person_id = content["data"]["createPersons"]["items"][0]["id"] + + created_person = Person.objects.get(id=person_id) + assert created_person.first_name == "Foo" + assert created_person.last_name == "Bar" + assert created_person.photo.file.name.endswith(".jpg") + assert created_person.avatar.file.name.endswith(".jpg") + + +def test_edit_person_with_file_upload(client_query, uploaded_picture): + p = Person.objects.first() + global_permission = Permission.objects.get( + codename="change_person", content_type__app_label="core" + ) + p.user.user_permissions.add(global_permission) + + p = Person.objects.create( + first_name="Foo", + last_name="Bar", + ) + + query = """ + mutation updatePersons($input: [BatchPatchPersonInput]!) { + updatePersons(input: $input) { + items: persons { + id + firstName + lastName + } + } + } + """ + + # Edit with adding files + variables = { + "input": [ + { + "id": p.id, + "firstName": "Foo", + "lastName": "Bar", + "photo": None, + "avatar": None, + }, + ] + } + files = { + "variables.input.0.photo": uploaded_picture, + "variables.input.0.avatar": uploaded_picture, + } + + response, content = client_query(query, variables=variables, files=files) + + p.refresh_from_db() + + assert p.first_name == "Foo" + assert p.last_name == "Bar" + assert p.photo.file.name.endswith(".jpg") + assert p.avatar.file.name.endswith(".jpg") + + # Edit without changing files + variables = { + "input": [ + { + "id": p.id, + "firstName": "Foo", + "lastName": "Baz", + }, + ] + } + files = {} + response, content = client_query(query, variables=variables, files=files) + p.refresh_from_db() + + assert p.first_name == "Foo" + assert p.last_name == "Baz" + assert p.photo.file.name.endswith(".jpg") + assert p.avatar.file.name.endswith(".jpg") + + # Edit with deleting files + variables = { + "input": [ + { + "id": p.id, + "firstName": "Foo", + "lastName": "Baz", + "photo": None, + "avatar": None, + }, + ] + } + response, content = client_query(query, variables=variables, files=files) + p.refresh_from_db() + + assert p.first_name == "Foo" + assert p.last_name == "Baz" + assert not p.photo + assert not p.avatar diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 71147f1c4586280bb630a9e45ebdcaebe3e6aa81..e9c908c1395a208d6c0471898d4ee758ea4608a8 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -45,7 +45,7 @@ from django_celery_results.models import TaskResult from django_filters.views import FilterView from django_tables2 import SingleTableMixin, SingleTableView from dynamic_preferences.forms import preference_form_builder -from graphene_django.views import GraphQLView +from graphene_file_upload.django import FileUploadGraphQLView from graphql import GraphQLError from guardian.shortcuts import GroupObjectPermission, UserObjectPermission from haystack.generic_views import SearchView @@ -1262,7 +1262,7 @@ class TwoFactorLoginView(two_factor_views.LoginView): return other_devices -class LoggingGraphQLView(GraphQLView): +class LoggingGraphQLView(FileUploadGraphQLView): """GraphQL view that raises unknown exceptions instead of blindly catching them.""" def execute_graphql_request(self, *args, **kwargs): diff --git a/conftest.py b/conftest.py index 1a448a6f93b2cc6ae536c916c8a9ab56d5b9fe5a..2c92d6b9540548ea2c6251381fef809cc1413679 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,9 @@ import json +from io import BytesIO +import PIL import pytest +from django.core.files.uploadedfile import InMemoryUploadedFile from graphene_django.settings import graphene_settings pytest_plugins = ("celery.contrib.pytest",) @@ -42,6 +45,52 @@ def graphql_query( return resp, content +def graphql_query_multipart( + query, + operation_name=None, + input_data=None, + variables=None, + headers=None, + files=None, + client=None, +): + """Do a GraphQL query for testing.""" + graphql_url = graphene_settings.TESTING_ENDPOINT + operations = {"query": query} + if operation_name: + operations["operationName"] = operation_name + if variables: + operations["variables"] = variables + if input_data: + if "variables" in operations: + operations["variables"]["input"] = input_data + else: + operations["variables"] = {"input": input_data} + body = { + "operations": json.dumps(operations), + } + map = {} + if files: + for idx, (key, value) in enumerate(files.items()): + file_key = f"file_{idx}" + body[file_key] = value + map[file_key] = [key] + body["map"] = json.dumps(map) + if headers: + header_params = {"headers": headers} + resp = client.post( + graphql_url, + data = body, + **header_params, + ) + else: + resp = client.post( + graphql_url, data=body + ) + content = json.loads(resp.content) + return resp, content + + @pytest.fixture def logged_in_client(client, django_user_model): """Provide a logged-in client for testing.""" @@ -61,6 +110,17 @@ def logged_in_client(client, django_user_model): def client_query(logged_in_client): """Do a GraphQL query with a logged-in client.""" def func(*args, **kwargs): - return graphql_query(*args, **kwargs, client=logged_in_client) + return graphql_query_multipart(*args, **kwargs, client=logged_in_client) return func + +@pytest.fixture +def picture(): + buf = BytesIO() + im = PIL.Image.new(mode="RGB", size=(200, 200)) + im.save(buf, format="JPEG") + return buf + +@pytest.fixture +def uploaded_picture(picture): + return InMemoryUploadedFile(picture, None, 'test.jpg', 'image/jpeg', None, None) diff --git a/pyproject.toml b/pyproject.toml index 5fcd4105aa59a8610abf41a2b03a203e31a27d89..b95e0214858e5ae5ba4dd28304e238b18ff4774d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,7 +132,12 @@ uwsgi = "^2.0.21" tqdm = "^4.66.1" django-pg-rrule = "^0.3.1" libsass = "^0.23.0" +<<<<<<< HEAD graphene-django-optimizer-reloaded = "^0.9.2" +======= +graphene-django-optimizer = { git = "https://github.com/bellini666/graphene-django-optimizer", rev = "3648b66" } +graphene-file-upload = "^1.3.0" +>>>>>>> d178faab (Support file uploads via GraphQL) [tool.poetry.extras] ldap = ["django-auth-ldap"]